I was recently working on a Phoenix LiveView project and we had a requirement to not write any JavaScript. This was fairly unusual to me. While Phoenix LiveView has enough tooling to help you write robust applications on its own, it’s pretty common to reach for JavaScript packages when you start doing more and more client-side work.

That said, we stuck to the requirement, and it was an interesting learning experience. I was actually surprised how much functionality we were able to implement without significant tradeoffs. LiveView works in such a way that you don’t need to write any JavaScript for most workflows. And even when you do eventually hit a point where you need JavaScript, how you use it can make a big difference to your developers.

First stop, JS commands

For simple visual changes (hiding a modal, adding a class to an element when the user clicks a button), you can reach for Phoenix.LiveView.JS, which contains commands for toggling visibility, setting focus, and adding / removing classes or attributes, etc. These work by generating serialized instructions which are embedded in the DOM and executed by the JavaScript code that powers LiveView in the client. So none of these require server communication once they are on the page.

Before the JS commands were added, many developers would use AlpineJS. These days, I don’t think there’s a strong need to do so. Many of the attributes and properties in AlpineJS deal with the fact that the DOM doesn’t have built-in ways to bind data, iterate over collections, etc. Instead of writing these on your own, or pulling in something like React, AlpineJS provides declarations for most of this, while being significantly smaller than comparable tools.

But we have the ability to do all of that with LiveView. Let’s contrast an example in the AlpineJS docs with similar code in LiveView.

AlpineJS:

defmodule ExampleComponent do
  use Phoenix.LiveComponent

  def render(assigns) do
    ~H"""
    <div x-data="{count: 0 }">
      <button x-on:click="count++">Increment</button>

      <span x-text="count"></span>
    </div>
    """
  end
end

LiveView:

defmodule ExampleComponent do
  use Phoenix.LiveComponent

  def mount(socket) do
    {:ok, assign(socket, :count, 0)}
  end

  def render(assigns) do
    ~H"""
    <div>
      <button phx-click="increment">Increment</button>

      <span><%= @count %></span>
    </div>
    """
  end

  def handle_event("increment", _params, socket) do
    {:noreply, assign(socket, :count, socket.assigns.count + 1)}
  end
end

This is an overly simple example, and frankly, that’s how a lot of side-by-side comparisons will look. The main difference is where your state is managed.

  • LiveView: state is on the server, DOM updates sent to client automatically.
  • AlpineJS (or any other JavaScript library): state is on the client, and it’s up the developer to decide out how to persist it to the server.

Things start to get a lot more interesting when you have a lot of client state to manage. That is, the state of your UI (ex. which accordions are open), not application data (ex. user settings).

Let’s implement an accordion and see how things differ.

AlpineJS:

defmodule Accordion do
  use Phoenix.LiveComponent

  def render(assigns) do
    ~H"""
    <div x-data="{open: false}">
      <button x-on:click="open = ! open">Open</button>
      <div x-show="open">
        Dropdown content...
      </div>
    </div>
    """
  end
end

LiveView:

defmodule Accordion do
  use Phoenix.LiveComponent

  def mount(socket) do
    {:ok, assign(socket, :open, false)}
  end

  def render(assigns) do
    ~H"""
    <div>
      <button phx-click="toggle_dropdown">Open</button>
      <div style={if @open, do: "display:block", else: "display:none"}>
          Dropdown content...
      </div>
    </div>
    """
  end

  def handle_event("toggle_dropdown", _params, socket) do
    {:noreply, assigns(socket, :open, !socket.assigns.open)}
  end
end

We could also implement this using JS commands, which removes the small but potentially noticeable lag that can occur when your diffs are larger. However, this removes any explicit state and pushes it to the DOM.

defmodule Accordion do
  use Phoenix.LiveComponent
  alias Phoenix.LiveView.JS

  def render(assigns) do
    ~H"""
    <div>
      <button phx-click={JS.toggle(to: "#content")}>Open</button>
      <div id="content" style="display:none">
          Dropdown content...
      </div>
    </div>
    """
  end
end

A common practice here is to actually do both: immediately toggle our styling via JS and update the state after. This gives you the best of both worlds. Instant feedback and managed state.

defmodule Accordion do
  use Phoenix.LiveComponent
  alias Phoenix.LiveView.JS

  def mount(socket) do
    {:ok, assign(socket, :open, false)}
  end

  def render(assigns) do
    ~H"""
    <div>
      <button phx-click={JS.toggle(to: "#content") |> JS.push("toggle_dropdown")>Open</button>
      <div id="content" style="display:none">
        Dropdown content...
      </div>
    </div>
    """
  end
end

There isn’t much you can do with AlpineJS that you can’t do with LiveView on its own. If you wanted to iterate over some data, you could use AlpineJS’s x-for declaration. Or use a for-comprehension in your Elixir code. The latter has the benefit of being nestable, in the event you need to render some nested or recursive data. Something that x-for doesn’t support.

When you get to a point when you need to support custom event dispatch, or want to create complex and/or reusable components, there are more tools available to us. There’s the JS.dispatch function which allows developers to emit custom events. If you need to react to a DOM event, but can’t do what you need with the other built-in JS commands, this can help. This will require your developers to write event listeners to handle the dispatch. For components, LiveView has the Phoenix.Component and Phoenix.LiveComponent modules which can power functional (stateless) and stateful components, respectfully.

Okay, but I need to do more stuff?

Even with these tools, you may want to do more than is possible by listening to DOM events or diffing your HTML. Perhaps you need to auto-format phone numbers as a user types it in. Or maybe you want to be able to write applications on top of the HTML5 canvas, but don’t want developers to have to write JavaScript to make it work. Instead of implementing your application logic in JavaScript, you can do something similar to how the JS commands work: implement Elixir functions that can output serializable instructions, embed those somewhere your JavaScript code can pick it up, and write the JavaScript code you need to parse and execute those instructions. Client hooks are perfect for this.

One thing that is underdiscussed is how all of this code gets organized. A pattern I believe more teams should adopt is writing small libraries to encapsulate parts of your system that need to use JavaScript. To be more pointed, application developers should not deal with this. It makes sense to start out implementing functionality directly in your application. However, once things are solid, extract it. The benefits of not needing to constantly context switch is incredibly valuable (and arguably one of the greatest benefits of LiveView).

At the end of the day, we want to be able to write performant, real-time applications and not have to worry too much about the underlying details. We don’t really need to worry about optimizations like separating client and application state, optimistic updates, etc. By leveraging what LiveView gives you out of the box, and building upon its programming model, you can avoid writing most application-level JavaScript.

Jeremy Neal

Person An icon of a human figure Status
Double Agent
Hash An icon of a hash sign Code Name
Agent 00124
Location An icon of a map marker Location
Baltimore, MD