Optimizing User Experience with LiveView


The Phoenix LiveView programming model allows developers to deliver applications more quickly, with feature sets previously only obtainable by single page application frameworks. LiveView is a perfect fit for real-time applications like dashboards and feeds, as well as applications with client interactions that necessarily require a round-trip to the server, ie chats, interactive forms, shopping carts, image uploads, etc.

But what about those interactions that don’t require round trips like toggling page content or interactions that demand zero-latency such as formatted inputs? In this post, we’ll see how to identify whether a round trip to the server is necessary and solutions that make purely client-side interactions a breeze to work with inside LiveView. Let’s get started!

Client vs Server

From a UX perspective, it’s vital to identify which interactions are expected to remain latency-free. For example, while loading dynamic content sometimes requires a trip to the server, formatting a user’s phone number as they type necessitates zero latency or the interaction is a clunky, frustrating experience. Follow these guidelines to answer where a client or server responsibility exists in your LiveView projects:

  1. If the user interaction is only about toggling content (show/hide, CSS classes, etc), prefer client-side, unless:

    • The toggled content is dynamically loaded to save either network or application load
    • The toggled content itself requires stateful changes to the backend, such as data subscriptions
  2. If the user interaction demands zero-latency, client-side code is the only solution, for example:

    • “controlled inputs” such as autoformatted dates, phone numbers, where a user’s input is translated as they are typing
  3. Otherwise, any interaction that necessarily involves the server is a perfect fit to happen in the server LiveView, for example:

    • Sending a message to other users
    • Saving information to the database
    • Adding items from inventory to a shopping cart
    • Uploading files

If you abide by these general guidelines, your LiveView UX will match or beat SPA counterparts because of the data optimizations LiveView is able to achieve, with far less code than a purely client-side solution. In fact, LiveView includes UI feedback features out-of-the-box to handle common scenarios for you. Let’s see how.

Built-in basics for Optimistic UI and UI Feedback

LiveView itself ships with a few client-based features that allow you to provide your users with instant feedback while waiting on potentially latent actions. For example, buttons can be annotated with phx-disable-with to swap textual content when a form is submitted:

<button type="submit" phx-disable-with="Saving...">Save</button>

When a LiveView form is submitted, the button will be instantly disabled with “Saving…”, then restored once the action is acknowledged by the server.

In addition to phx-disable-with, Phoenix LiveView also includes a number of CSS based features to provide immediate user feedback. When an element is interacted with by the user with a phx- binding, a CSS class is applied on the client and remains active until that specific interaction is acknowledged by the server. This means that any inflight updates from the server won’t race the UI and we can be sure the class remains until the server processed our action. For example, imagine the following button is clicked by the user:

<button phx-click="increment">+</button>

LiveView will apply the phx-click-loading class:

<button phx-click="increment" class="phx-click-loading">+</button>

This allows purely CSS based UI updates to provide feedback to your users. All LiveView events include such class names, based on the event type, including phx-change-loading and phx-submit-loading for phx-change, phx-submit, and so on.

Likewise, LiveView dispatches global events for live page navigation, which allows you to provide the user with feedback for main page changes.

You may have seen code similar to this in your app.js when generating applications with mix phx.new --live:

// Show progress bar on live navigation and form submits
topbar.config({...})
window.addEventListener("phx:page-loading-start", info => topbar.show())
window.addEventListener("phx:page-loading-stop", info => topbar.hide())

LiveView emits phx:page-loading-start and phx:page-loading-stop on window, which can be used above to show an animation bar across the page until the loading is complete. Here we used the tiny topbar library to show our loading bar, but any UI accommodations may be applied here to suit your requirements. Likewise, non-navigation based events can trigger the phx:page-loading-start/stop events by annotating the tag with phx-page-loading. For example:

<button phx-click="reserve" phx-page-loading>Reserve</button>

Clicking on this button would apply both the phx-click-loading CSS class and emit the phx:page-loading-start event. Likewise, once the server acknowledges our event, the CSS class is removed and the phx:page-loading-stop event is emitted. This allows you to trigger your main loading UI states for any LiveView event as necessary.

These basic UI feedback features are a great starting point, but sometimes more interactivity is required which can only be achieved on the client. Next, we’ll explore concrete examples of client-side solutions within a LiveView application.

Leaning on the client

LiveView allows developers in large part to free themselves of client-side concerns, but the fact we can execute code on the client is not only necessary for LiveView to exist, but an asset for developers to lean on when they require a JavaScript escape hatch for client-side interactions. Let’s explore a couple of use cases to see how we can get the best of both worlds with LiveView and client-side code while optimizing for user experience.

Imagine we want to toggle a container in our layout. This could include user navigation dropdowns, expanding menus, etc. We could use LiveView’s phx-click to toggle some server state to show/hide content in our LiveView template, but we’ve outlined how purely client-side interactions do not need to talk to the server, so let’s lean on the client to do this work for us. Phoenix includes a JavaScript escape hatch via phx-hook for executing custom JavaScript on DOM nodes, but we can often accomplish our needs without writing any external JavaScript at all with Alpine.js.

Alpine.js is a JavaScript library purpose-built for LiveView-like applications where quick JavaScript interop allows you to accomplish your goals in shockingly small amounts of code. Let’s see how.

Imagine we’re building a drop-down menu for the signed-in user’s navigation bar. We might start with something like this:

<div class="relative"> <div class="flex items-center"> <div class="ml-3"> <p class="text-sm leading-5 font-medium group-hover:text-gray-900"> <%= @current_user.name %> </p> </div> </div> <div class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg"> <div class="py-1 rounded-md bg-white shadow-xs"> <a href="" class="block px-4 py-2 text-sm leading-5 hover:bg-gray-100"> Notifications </a> <a href="" class="block px-4 py-2 text-sm leading-5 hover:bg-gray-100"> Profile </a> </div> </div>
</div>

Now we have a nice looking drop-down with our user’s avatar and name:

drop-down-static

This is looking great, but we only want the drop-down to appear when clicked. Let’s use Alpine to toggle the container. Just like Phoenix LiveView allows us to annotate our markup for server interactions, Alpine allows us to annotate our markup for client interactions. For example:

<div class="relative"
+ x-data="{open: false}"
+ x-on:click="open = true"
+ x-on:click.away="open = false"
> <div class="flex items-center"> <div class="ml-3"> <p class="text-sm leading-5 font-medium group-hover:text-gray-900"> <%= @current_user.name %> </p> </div> </div> <div class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg"
+ x-show.transition="open" > <div class="py-1 rounded-md bg-white shadow-xs"> <a href="" class="block px-4 py-2 text-sm leading-5 hover:bg-gray-100"> Profile </a> <a href="" class="block px-4 py-2 text-sm leading-5 hover:bg-gray-100"> Signout </a> </div> </div>
</div>

Here, we added a few attributes to our container, x-data, x-on:click, and x-on:click.away. The x-data attribute tells Apline this is an Alpine DOM node and what state this Alpine node holds, and the x-on:click bindings allows us to execute JavaScript when the container is clicked, and when the container is clicked away from. We also annotated the menu item container with x-show.transition="open". By simply setting the Alpine node’s state via regular JavaScript expressions open = true or open = false, we can control the toggling of our menu! The x-show and x-show.transition annotations in Alpine allow us to toggle the display status of a DOM node, where .transition modifier applies a quick animation for pleasant UX. Annotating our markup this way allows simple JavaScript interactions to exist right inside your LiveView templates, and LiveView’s own DOM patching plays nicely with the client-controlled Alpine state. Reloading our app looks like this:

drop-down-transition

Very nice! With just a few Alpine annotations, we have the exact interaction we want, and it happens instantly on the client as it should. Most importantly, it plays nicely with LiveView’s own DOM patching, as long as we tell LiveView how to maintain Alpine properties. This can be done in your app.js:

+import Alpine from "alpinejs"
let liveSocket = new LiveSocket("/live", Socket, { params: {_csrf_token: csrfToken},
+ dom: {
+ onBeforeElUpdated(from, to){
+ if(from.__x){ Alpine.clone(from.__x, to) }
+ }
+ }
})

Now when LiveView patches the DOM, we tell Alpine to clone the DOM nodes that it is tracking. Next let’s explore a scenario where client-side code is our only option: controlled inputs.

Controlled inputs often refer to input tags that transform their values as the end-user is typing, for example formatting a birthday or phone number. For LiveView applications, we can’t rely on the server to do this work because latency of any form is not acceptable. Let’s see how easy Alpine makes this within a LiveView:

<form phx-change="validate" phx-submit="save"> ... <input name="phone" type="text" value="<%= @phone %>" x-data x-on:input=" $el.value = $el.value.replace(/\D/g,'').replace(/(\d{3})(\d{3})(\d{4})/,'($1)$2-$3') " />
</form>

Here we used the x-on:input binding to execute some JavaScript when the "input" event is triggered. Alpine makes a few variables available to our code expressions, such as $el for our bound Alpine element. When an input event is fired on user interaction, we simply format the input value. This happens before LiveView’s own form events, so the LiveView server will receive the formatted value. Here’s what it looks like in action:

phone-format

Alpine can do much more than what we’ve seen here, but it really shines for use cases throughout this post – toggling content, showing tabs, popping modals or notifications, and handling controlled inputs.

I hope this gives you a taste of what’s possible with Alpine along with insight around what LiveView provides out of the box to ensure great UX in your applications. Happy Coding!

DockYard is a digital product consultancy specializing in user-centered web application design and development. Our collaborative team of product strategists help clients to better understand the people they serve. We use future-forward technology and design thinking to transform those insights into impactful, inclusive, and reliable web experiences. DockYard provides professional services in strategy, user experience, design, and full-stack engineering using Ember.js, React.js, Ruby, and Elixir. From ideation to delivery, we empower ambitious product teams to build for the future.