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
:
Note that when we push an event from the server using Phoenix.LiveView.push_event/3
(as we’ll do later), the event name is dispatched in the browser with the phx:
prefix.
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!