User experience in LiveView applications with Alpinejs

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.

Table of contents

    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>

    cookie eater

    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>

    Enhancing user experience in LiveView applications with Alpine.js-2

    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>

    alert

    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 on window instead of on element
    • .stop - equivalent to stopPropagation(), 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>

    alert2

    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.

    Artur Ziętkiewicz
    Artur Ziętkiewicz Elixir & React Developer

    Read more
    on #curiosum blog