Triggering JS From the Server in LiveView: Showing a Spinner

It's always frustrating when we click something—and it looks like nothing's happening. The default solution to this is to throw up a spinner while the server is chewing on a request, to help users resist the temptation to keep clicking. UX win!

We can do this on the client, and poll the server repeatedly, asking “Are you done yet?” But polling adds waiting time as well as traffic and load on the server. The server knows what it’s doing, and it knows when it’s done. We should get the server to show and hide the "Please wait while I get that for you…” spinner.

With LiveView, we have the tools!

Problem

How can we create a loader that the server makes appear and disappear? And can we make that into a reusable component?

Solution

Today we'll create a loader component that appears asynchronously when we make a request to an external API that may take time to respond. For that we'll apply an interesting trick; we'll trigger JS commands from the server side!

Defining a Loader Component

Before we start, I want to mention that my abilities developing CSS and HTML are not the best, so I used the amazing spinner designed and developed by Vasili Savitski and Epicmax; you can find many others here.

We package up the spinner into a loader function component that we can reuse:

def loader(assigns) do
  ~H"""
  <div class="hidden h-full bg-slate-100" id={@id}>
    <div class="flex justify-center items-center h-full">
      <div class="flower-spinner">
        <div class="dots-container">
          <div class="bigger-dot">
            <div class="smaller-dot"></div>
          </div>
        </div>
      </div>
    </div>
  </div>
  """
end

The spinner itself is just a collection of HTML elements within our component container, and its own CSS takes care of the fancy positioning and animation of its nested elements to create the pretty spinny pattern.

We use Tailwind classes to match the loader container size to its parent HTML element, and to center the spinner within its parent. The hidden class makes the spinner invisible by default.

Let's render our spinner (without the hidden class) to see how it looks:

def render(assigns) do
  ~H"""
    <.loader id="my_spinner"/>
  """
end

Showing and Hiding the Loader

Now, how do we make our loader appear and disappear? JS commands!

defp show_loader(js \\ %JS{}, id) do
  JS.show(js, 
    to: "##{id}", 
    transition: {"ease-out duration-300", "opacity-0", "opacity-100"}
  )
end

defp hide_loader(js \\ %JS{}, id) do
  JS.hide(js, 
    to: "##{id}", 
    transition: {"ease-in duration-300", "opacity-100", "opacity-0"}
  )
end

We use the JS.show and JS.hide commands, each one with a simple transition that changes the opacity of the loader's container.

The commands are encapsulated inside the show_loader/2 and hide_loader/2 functions just for simplicity, as we'll use them later.

Using the commands we defined above, we can show and hide our loader on the client side just by using phx-bindings like phx_click:

<button phx-click={show_loader("my_spinner")}>
  Without the server!
</button>

However, sometimes only the server (and the logic we define) knows when the processing has finished and the loader can be hidden again. This is where we apply the most important trick of this recipe!

Triggering a JS Command From the Server Side

The idea here is to push an event to the client from the server side each time we want to show or hide our loader, and have the event's JS listener trigger the JS command we want. Neither the server nor the listener really needs to know exactly what should happen on the client when this event arrives! So here's how we break it up:

  • The loader's outer HTML element has data-* attributes that store the JS Commands or functions that should be invoked when we start waiting, and when we’re done waiting.
  • The server pushes an event whose payload consists of 1) an element id to target and 2) the name of a data attribute, like data-ok-done.
  • The listener executes the JS indicated by the specified data-* attribute on the target element.

This is a highly-reusable pattern! Let's use it:

First we specify the JS commands we want to trigger by adding them as attributes of our loader main container:

def loader(assigns) do
  ~H"""
  <div 
    class="hidden h-full bg-slate-100" id={@id}
    data-plz-wait={show_loader(@id)} 
    data-ok-done={hide_loader(@id)}
  >
    .
    .
    .
  </div>
  """
end

The JS.show command we defined in the show_loader/2 function is embedded inside the data-plz-wait attribute (the same happens with the data-ok-done attribute). In both cases, we pass the identifier of our loader as a parameter.

In our app.js file we add an event listener for the server-pushed event js-exec:

window.addEventListener("phx:js-exec", ({detail}) => {
    document.querySelectorAll(detail.to).forEach(el => {
        liveSocket.execJS(el, el.getAttribute(detail.attr))
    })
  })

This listener is a generic solution: we can trigger any JS command (we can even execute a set of JS commands!) just by embedding them inside an HTML attribute.

Let's see how it works:

The listener function receives a detail object, which has two attributes: to and attr. to contains the identifier of one or more HTML elements, and attr is the name of the HTML attribute that embeds the JS command we want to trigger.

For each element matching the to identifier, we trigger the JS command contained in the element's HTML attribute attr.

Finally, we can trigger our js-exec event by adding it to the socket and pushing it to the client by using the push_event/3 function:

push_event(socket, "js-exec", %{
  to: "#my_spinner", 
  attr: "data-ok-done"
})

We send a map with the details that the listener is waiting for: the identifier of our spinner, and the name of the attribute that embeds the JS command we want to trigger.

This way we push the js-exec event to the client, and the listener receives the event and triggers the command embedded in the data-ok-done attribute.

Loader in Action

Speaking of limited skills, it's difficult for me to choose colors that look good together, so an API like this one that generates a random color palette is very useful.

For our example, when we click a button, we send a request to the Palett API and display the generated colors in an interesting way:

We'll define a new LiveView for our example. We won't include all the content here. You can check out the full repo to see everything. In this recipe we'll go over the most important details.

The trick to using our loader lies in just a couple of functions:

def handle_event("create_palette", _value, socket) do
  send(self(), :run_request)

  socket = push_event(socket, "js-exec", %{
    to: "#my_spinner", 
    attr: "data-plz-wait"
  })

  {:noreply, socket}
end

def handle_info(:run_request, socket) do
  socket =
    socket
    |> assign(:colors, get_colors())
    |> push_event("js-exec", %{to: "#my_spinner", attr: "data-ok-done"})

  {:noreply, socket}
end

When the create_palette event is received from the button, we send the :run_request message to our own component and add the js-exec event to the socket just before returning the {:noreply, socket} tuple. This way, we process the :run_request message while the spinner is displayed and it stays there.

On the other hand, the handle_info callback is in charge of asynchronously calling the API and adding the event to the socket to hide the spinner. Once the colors are fetched from the API, no matter how long it took to respond, the loader is hidden.

Discussion

We created a reusable loader, and with only 4 lines of JavaScript, got the server to display it using JS commands! I can say that it took me more effort to think and come up with a fun example than to create the solution.

You can build on this. Maybe you have a background job that builds an invoice on demand. If you use Phoenix PubSub, then your far-away server can notify the waiting LiveView when the job is done, and your spinner can vanish as the invoice or link appears. The possibilities are myriad. I'll be keeping an eye out for other solutions that involve triggering JS commands with server events!