Enhancing user experience in LiveView applications with Alpine.js
Using Alpine.js to handle JavaScript events in Phoenix LiveView applications significantly enhances your web app's interactivity.
The PETAL stack
The PETAL stack consists of:
- Phoenix - The web framework providing robustness and reliability.
- Elixir - The dynamic, functional language designed for scalable and maintainable applications.
- TailwindCSS - A utility-first CSS framework for rapidly building custom designs.
- Alpine.js - A minimal framework for composing JavaScript behaviour, our star today.
- LiveView - Enables rich, real-time user experiences with server-rendered HTML.
In this article, I'll present the "A" part of the PETAL, which stands for Alpine.js. LiveView's capability of handling real-time user events is a powerful tool, but using it for a simple UI interaction is like using a sledgehammer to crack a nut. You definitely can, but there are more appropriate tools for that task.
LiveView characteristics
One of the LiveView applications' idiosyncrasies is that every user event fired in the application needs to make a roundtrip to the LiveView process on the server, either via a WebSocket persistent connection or regular HTTP requests and long-polling. It's great for sure to have real-time communication with the server or backend-backed HTML validation, but for mundane actions like toggling tabs or opening hamburger menus, it adds an unnecessary layer of abstraction which causes delay.
Maximum response time for user event
Based on research, to feel that a webpage is reacting instantaneously, the maximum time before a user gets visual feedback must not exceed 100 ms. It alone takes longer to send and receive an HTTP request on slow network connections.
Another crucial factor is the physical server location. For example, the absolute minimum time for a full roundtrip between a server located in San Francisco and a client in Poznań, Poland is 60 ms, assuming that the signal travels at the speed of light in a vacuum.
Alpine.js to the rescue
Alpine is a minimal framework for composing JavaScript behaviour directly from your templating language or HTML code. It allows handling many UI interactions directly in the client's browser, reducing the need for server roundtrips and significantly enhancing user experience.
Installation in Phoenix applications
Unlike TailwindCSS, which comes pre-packaged with Phoenix, Alpine is not included by default. However, its installation process is quite straightforward.
First, install the NPM dependency:
cd assets && yarn add alpinejs
The last step is to import it in assets/js/app.js
file and instruct Alpine to keep track of Phoenix LiveView updates.
import Alpine from "alpinejs";
window.Alpine = Alpine;
Alpine.start();
// liveview config
const liveSocket = new LiveSocket('/live', Socket, {
dom: {
onBeforeElUpdated(from, to) {
if (from._x_dataStack) {
window.Alpine.clone(from, to)
}
}
},
params: { _csrf_token: csrfToken },
});
State management
The ease of state management in Alpine.js is one of its main advantages. It is done with the directive x-data
, which defines the initial state as a POJO (Plain Old JavaScript Object).
With that in mind, let's create a simple cookie counter.
<button
type="button"
x-data="{ count: 0 }"
x-on:click="count += 1"
>
Eat the cookie (<span x-text="count" />)
</button>
In the example above I have also used the x-on
directive, which allows handling dispatched DOM events.
The state is block-scoped, meaning properties defined in the x-data
are available to all children elements. Also, nested properties may overwrite previously defined ones.
<div x-data="{ foo: 'fizz' }">
<span x-text="foo"><!-- displays "fizz" --></span>
<div x-data="{ bar: 'buzz' }">
<div x-data="{ foo: 'fizz2' }">
<span x-text="foo"><!-- displays "fizz2" --></span>
<span x-text="bar"><!-- displays "buzz" --></span>
</div>
</div>
</div>
Because the state in Alpine.js is just a regular JavaScript object, you can also declare functions, and even getters or setters.
<div x-data="{
showAlert() { alert('boom!') }
}">
<button @click="showAlert()" type="button">
Detonate
</button>
</div>
No magic is done under the hood, but Alpine still needs to evaluate the code as plain strings from HTML attributes. If you have a strict Content-Security Policy defined on your website, there is an alternate build with more restrictive syntax that works in such environments.
Above I have used a shorthand syntax for x-on:click
which is @click
, but both do exactly the same. This syntax may be misleading with assigns, because they also start with @
. Whether you like it or not, it’s important to choose one convention and stick to it throughout the project.
More on directives
In the previous section, I've introduced x-data
and x-on
. Now let's briefly discuss some other commonly used directives, if you are interested the full list can be found on the Alpine.js web page.
Hiding and showing elements
Changing the visibility of components is one of the simplest yet one of the most common operations on web pages. Alerts, popups, tabs or toggles lie in this category.
In Alpine, it is achieved with the x-show
directive. It applies a display
property with either none
or block
values. This means the element remains in the DOM (source of the page) but is not visible or interactive.
<div x-data="{ open: false }">
<button @click="open = !open" type="button">
Toggle alert
</button>
<div x-show="open">
Some alert...
</div>
</div>
Adding the x-transition
directive to the element with x-show
enables a smooth opacity transition!
Hint: if by default the element with x-show
is closed, use the x-cloak
directive to prevent it from flickering. First meaningful paint with HTML and CSS is done before JS on the page is executed, and this element may be visible for a fraction of a second. Adding x-cloak
keeps this element hidden.
<div x-data="{ open: false }">
<button @click="open = !open" type="button">
Toggle alert
</button>
<div x-show="open" x-transition x-cloak>
Some alert...
</div>
</div>
In the example above I have added a smooth opacity transition with x-transition
and x-cloak
not to show this element before JS is executed.
Removing elements from DOM
A more advanced technique entirely removes the element from the page, instead of just applying the CSS display
property. It involves using the <template>
element with the x-if
directive.
<div x-data="{ open: false }">
<button @click="open = !open" type="button">
Toggle alert
</button>
<template x-if="open">
Some alert...
</template>
</div>
Because this element is removed from the DOM, it does not support smooth opacity toggle with x-transition
. Another limitation is that <template>
must have only one root child element.
Reacting to actions
Handling inputs
The x-model
directive handles input changes and automatically updates the corresponding property.
It works with
<input type="text">
<textarea>
<select>
<input type="checkbox">
<input type="radio">
<input type="range">
Let's take a look at an example.
<div x-data="{ firstName: '' }">
<input type="text" x-model="firstName">
First name: <span x-text="firstName"></span>
</div>
Handling mouse and keyboard actions
With the previously introduced x-on
directive, Alpine makes it easy to run code on dispatched DOM user events.
Alpine goes even further and adds powerful modifiers to customize the behaviour of listeners.
Here are some examples of modifiers:
.outside
- listen for click outside of this element.window
- registers event onwindow
instead of on element.stop
- equivalent tostopPropagation()
, which does not allow an event to "bubble" up to ancestors elements.debounce
- fires event only after a certain period of inactivity (by default 250 ms).throttle
- similar to debounce, but fires event every 250 ms, instead of debouncing it indefinitely
When it comes to keyboard events, Alpine adds keydown
and keyup
directives with modifiers corresponding to each key, for example, .enter
or .escape
. A full list of keys is available in the Alpine documentation.
<input type="text" @keyup.enter="alert('Submitted!')">
Let's update the alert code and make it closeable on mouse click outside of the popup and on the escape key.
<div x-data="{ open: false }">
<button @click="open = true" type="button">
Show alert
</button>
<div
@click.outside="open = false"
@keydown.escape.window="open = false"
x-show="open"
x-transition
x-cloak
>
Some alert...
</div>
</div>
All of those functionalities with just a few lines of code!t
Changing the page's content
To dynamically update the content of an element, use the x-text
directive.
<div x-data="{ company: 'Curiosum' }">
Company: <strong x-text="company"></strong>
</div>
The code above is equivalent to setting JS's .innerText
property.
Sometimes you may also want to change the content of the element's attribute. One common use case is to update class
conditionally to change element appearance using CSS classes.
<div x-data="{ open: false }">
<button @click="open = !open" type="button">Toggle dropdown</button>
<div x-bind:class="open ? '' : 'hidden'">
Some content...
</div>
</div>
Warning: There is also an abbreviated syntax for x-bind:attribute="..."
, leaving just :attribute="..."
. This syntax is forbidden in LiveView, reserved for special HTML attributes :if
, :for
and :let
.
Adding a little bit of magic
Magic properties are one of the more advanced features. They start with a dollar sign ($
) and give lower-level control over Alpine elements.
Accessing the current element
Just like any property defined with x-data
, within directives, you can use $el
magic to access the current element.
Let's see an example that handles a click event and scrolls to the button that fired it.
<div x-data="{
scrollTo(el) { el.scrollIntoView(); }
}">
<button
@click="scrollTo($el)"
>
Click to scroll
</button>
</div>
References to other elements
If you are familiar with refs in React, you'll feel at home.
Refs are available globally within the whole Alpine app, which means that you can access any Alpine-rendered element.
To create a ref, just add an x-ref
directive with a unique value to the element you want to access, and then you can access this element globally with $refs[refName]
.
Let's modify the previous example to scroll to the specified element.
<div x-data="{
scrollTo(el) { el.scrollIntoView(); }
}">
<button
@click="scrollTo($refs.article)"
type="button"
>
Scroll to the article
</button>
...
<article x-ref="article">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</article>
</div>
Refs are often used as a more succinct and scoped alternative to document.querySelector
.
Dispatching custom browser events
With a $dispatch
function, you can dispatch your custom events, and then handle them with x-on:
/ @
.
<div x-data @notify="alert($event.detail)">
<button
@click="$dispatch('notify', 'Some notification')"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
type="button"
>
Show alert
</button>
</div>
Note: with custom events, you need to add x-data
to signify that this element is an Alpine component.
These magics are only a few examples, more can be found in Alpine docs.
The future of Alpine with LiveView JS commands
LiveView JS commands are a new feature in Phoenix LiveView, allowing you to define some actions to be done on the client side with phx-
bindings.
Currently, they allow adding or removing CSS classes, as well as hiding and showing elements. First, we need to define functions that leverage the JS
module. It works great either with function components or with stateful components.
Here's a brief preview showing the functionality in action:
alias Phoenix.LiveView.JS
defp toggle_tab(js \\ %JS{}, index) do
js
|> JS.hide(to: ".tab-content")
|> JS.show(to: "#tab-content-#{index}")
|> JS.remove_class("active", to: ".active.tab-button")
|> JS.add_class("active", to: "#tab-button-#{index}")
end
Then, commands can be executed directly from the templating language. The generated code stays in the browser, which means we don't need to wait for a response from the server.
~H"""
<div>
<button phx-click={toggle_tab("1")} type="button" id="tab-button-1" class="tab-button">
First tab
</button>
<button phx-click={toggle_tab("2")} type="button" id="tab-button-2" class="tab-button">
Second tab
</button>
<div phx-mounted={toggle_tab{"1")}>
<div class="tab-content" id="tab-content-1">
Lorem ipsum 1...
</div>
<div class="tab-content" id="tab-content-2">
Lorem ipsum 2...
</div>
</div>
</div>
"""
Another feature is the possibility to dispatch JavaScript events with JS.dispatch/1
and then handle them with window.addEventListener
defined in app.js
js file.
Final thoughts
Some say that commands will eventually replace the need for Alpine.js. I think they are enough for basic websites that just need to show alerts or contain basic tabs, but not for more advanced cases when greater control over the state is needed.
One issue is that there is no substitute for x-cloak
. Of course, we have phx-mounted
that I've used in the example above to set the default state, but after a page is refreshed we need to wait not only for JS to be executed, but for WebSocket connection to be established with the LiveView's own process. This causes even longer flickering than with Alpine without x-cloak
, depending on the user's internet connection.
A great advantage of Alpine.js over commands is that it works in regular Phoenix applications, without the need to add a LiveView abstraction layer.
Keeping those limitations in mind, I think that Alpine will remain a vital part of the PETAL stack for a long time.
FAQ
What is Alpine.js and how does it enhance user experience in LiveView applications?
Alpine.js is a minimal JavaScript framework that allows handling UI interactions directly in the client's browser, reducing server roundtrips and enhancing user experience, especially in Phoenix LiveView applications.
How does the PETAL stack contribute to web development?
The PETAL stack combines Phoenix, Elixir, TailwindCSS, Alpine.js, and LiveView, offering a robust, maintainable, and efficient framework for developing dynamic and scalable web applications.
How do you integrate Alpine.js into Phoenix applications?
To integrate Alpine.js into Phoenix applications, first install the Alpine.js NPM dependency, then import it in the assets/js/app.js
file, and configure it to track Phoenix LiveView updates.
What are the benefits of using Alpine.js for state management in web applications?
Alpine.js simplifies state management by allowing developers to define initial state with the x-data
directive and manipulate it directly in HTML, leading to cleaner and more maintainable code.
How does Alpine.js handle user interactions like inputs and mouse events?
Alpine.js uses directives such as x-on
and x-model
to handle user interactions like text inputs and mouse or keyboard events, simplifying the process of creating interactive UI components.
How does Alpine.js improve content visibility and element management in web applications?
Alpine.js offers directives like x-show
, x-transition
, and x-cloak
to manage the visibility and presentation of elements, improving the user experience with smoother transitions and controlled element display.
Can Alpine.js and LiveView JS commands work together in Phoenix applications?
While LiveView JS commands offer basic client-side interactivity, Alpine.js provides more control and flexibility for managing state and UI interactions, suggesting that both can coexist to enhance user experience in different scenarios.
What are some advanced features of Alpine.js, and how do they enhance web development?
Alpine.js offers advanced features like magic properties and custom browser event dispatching, which provide developers with lower-level control and flexibility in creating interactive and dynamic web pages.