12k
All articles

Building Interactive Apps with Phoenix LiveView

Build a Phoenix LiveView search app with server-side state, HEEx templates, events, and a colocated JavaScript hook. See when LiveView fits or falls short.

OpenReplay Team
OpenReplay Team
Building Interactive Apps with Phoenix LiveView

Phoenix LiveView renders HTML on the server, keeps each page’s state in a long-running Elixir process, and sends only the changed parts of the page to the browser over a persistent connection.

If you build CRUD screens in React or Vue, you know the routine: a JSON endpoint, a client-side store, loading states, and validation written once per side. Most of that code only moves data between two copies of the same state.

This article shows how LiveView removes the API layer for that kind of app. You’ll build a live search box with LiveView 1.2 syntax, add one piece of client-side JavaScript, and see where the model stops fitting.

Key Takeaways

  • A LiveView keeps UI state in a server process. Browser events go over a WebSocket, and LiveView sends back only the HTML that changed.
  • A LiveView’s first render is a plain HTTP response, and mount/3 runs again once the WebSocket connects.
  • A basic LiveView needs three callbacks: mount/3 sets the initial state, render/1 returns a HEEx template, and handle_event/3 updates state in response to bindings such as phx-click and phx-change.
  • Colocated hooks put a component’s JavaScript in a <script> tag next to its markup. They require Phoenix 1.8 or later.
  • Every event handled on the server is a network round trip. React or Vue is still the better choice for offline-first apps and UIs with heavy client-side interaction.

What Does Phoenix LiveView Do?

Phoenix LiveView moves state, event handling and rendering from the browser to the server, while the browser keeps a live connection open to receive updates. The LiveView docs describe each LiveView as a process: it takes in events, changes its state, and sends the page only the parts that differ.

Mapped onto the SPA model you already know:

SPA conceptLiveView equivalent
Component state (useState, ref)Assigns on the server-side socket
Event handler (onClick)handle_event/3 callback
Virtual DOM patchHTML diff pushed over the WebSocket
API call to fetch dataA function call inside the same process

The lifecycle runs in two phases. The first render is an ordinary HTTP response, so the page is visible and indexable before any JavaScript loads. The browser then opens a WebSocket, and mount/3 runs a second time inside a process that stays alive for as long as the page is open. Code in mount/3 therefore runs twice per page load.

If you’ve used htmx, the idea of driving the UI with server-rendered HTML will feel familiar. The difference is that LiveView also keeps a stateful process on the server for each connected page.

How Do You Create a Phoenix Project and Add a Live Route?

A new Phoenix 1.8 app comes with LiveView already set up, so a single generator command gives you everything this example needs. After you install Elixir and the Phoenix installer, run:

mix phx.new my_app --no-ecto
cd my_app
mix phx.server

The --no-ecto flag skips the database layer, which a demo doesn’t need. The dev server listens on http://localhost:4000. You don’t need a --live flag: older tutorials pass one, but LiveView is now part of every generated app.

Open lib/my_app_web/router.ex. Inside the existing scope "/", MyAppWeb do block, under pipe_through :browser, add:

live "/search", SearchLive

The live macro maps a path to a LiveView module, much as get maps a path to a controller. Substitute your own app name wherever you see MyApp. Phoenix ships with generators, auth scaffolding and LiveView out of the box, which makes it a batteries-included framework.

How Do You Build a Live Search Box with Phoenix LiveView?

A live search box needs one form binding that reacts as you type and one click binding that resets the state. Create lib/my_app_web/live/search_live.ex:

defmodule MyAppWeb.SearchLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, assign(socket, query: "", results: frameworks())}
  end

  def render(assigns) do
    ~H"""
    <form phx-change="search" phx-submit="search">
      <input
        id="search-input"
        type="text"
        name="q"
        value={@query}
        placeholder="Filter frameworks"
        autocomplete="off"
        phx-debounce="300"
      />
    </form>
    <button phx-click="clear">Clear</button>
    <p>{length(@results)} matches</p>
    <ul>
      <li :for={name <- @results}>{name}</li>
    </ul>
    """
  end

  def handle_event("search", %{"q" => q}, socket) do
    {:noreply,
     socket
     |> assign(:query, q)
     |> assign(:results, filter(q))}
  end

  def handle_event("clear", _params, socket) do
    {:noreply, assign(socket, query: "", results: frameworks())}
  end

  defp frameworks do
    ["Phoenix", "Rails", "Laravel", "Django", "Next.js", "Nuxt", "SvelteKit", "Remix"]
  end

  defp filter(q) do
    needle = String.downcase(q)
    Enum.filter(frameworks(), &String.contains?(String.downcase(&1), needle))
  end
end

mount/3 receives params, session and socket in that order, and returns the socket with initial assigns. render/1 returns a HEEx template built with the ~H sigil. Each of phx-change, phx-submit and phx-click sends a named event to handle_event/3, which matches on the event name, returns {:noreply, socket} with updated assigns, and lets LiveView push a diff of the affected HTML.

phx-change fires as the user types, so each keystroke would be a round trip. phx-debounce rate-limits it and accepts milliseconds or "blur". Pointing phx-submit at the same event means LiveView handles the Enter key instead of the browser posting the form. The form bindings guide shows that form params arrive as a string-keyed map named after the inputs, such as %{"q" => "pho"}.

Templates interpolate with {@query}. Tutorials that use <%= @query %> predate the current syntax. The :for attribute is HEEx’s shorthand for a comprehension.

The Elixir you need for this file:

  • Atoms such as :ok, :noreply and :query are named constants, similar to string enums.
  • Tuples such as {:noreply, socket} are fixed-size groups of values. Callbacks return them to say what happened.
  • Pattern-matched function heads: the two handle_event/3 clauses are separate definitions, and Elixir runs the one whose first argument matches the event name. %{"q" => q} destructures the params the way const { q } = params does in JavaScript.
  • Pipes: |> passes the value on its left as the first argument of the next call.
  • Immutability: assign returns a new socket. Nothing is mutated in place.

How Do You Add Client-Side JavaScript with a Colocated Hook?

A colocated hook lets you write a component’s JavaScript in a <script> tag right next to its markup. The hook name starts with a dot, and the element it attaches to must have a unique DOM id. The JS interop guide covers the full hook API.

The example below adds keyboard shortcuts: / focuses the search field and Escape clears it. Replace the <input> in render/1 with:

<input id="search-input" type="text" name="q" value={@query}
  placeholder="Filter frameworks" autocomplete="off"
  phx-debounce="300"
  phx-hook=".SearchShortcuts" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".SearchShortcuts">
  export default {
    mounted() {
      this.onKey = (e) => {
        if (e.key === "/" && document.activeElement !== this.el) {
          e.preventDefault()
          this.el.focus()
        }
        if (e.key === "Escape" && document.activeElement === this.el) {
          this.el.value = ""
          this.el.blur()
          this.pushEvent("clear", {})
        }
      }
      window.addEventListener("keydown", this.onKey)
    },
    destroyed() {
      window.removeEventListener("keydown", this.onKey)
    }
  }
</script>

mounted runs when the element is added to the page and destroyed runs when it’s removed, so the listener is attached once and then cleaned up. pushEvent("clear", {}) triggers the same handle_event("clear", ...) clause that the button uses, so client and server share one code path. Clearing and blurring the field before pushing the event keeps the visible value in step with the server state.

The ColocatedHook module extracts the script at compile time. A dot-prefixed name is namespaced with the module name, and the feature requires Phoenix 1.8 or later. The app.js generated by mix phx.new already registers these hooks:

import {hooks as colocatedHooks} from "phoenix-colocated/my_app"
// ...
hooks: {...colocatedHooks}

When Does LiveView Fit and When Doesn’t It?

Every event that handle_event/3 handles is a round trip to the server. A LiveView feels instant on a fast network and laggy on a slow one. Purely visual changes, such as opening a dropdown, can run in the browser with Phoenix.LiveView.JS commands and never reach the server.

Localhost hides that latency. The assets/js/app.js generated by mix phx.new already exposes the socket as window.liveSocket, so to simulate a slow connection, run this in the browser console:

liveSocket.enableLatencySim(500)
// later
liveSocket.disableLatencySim()

The latency simulator adds the number of milliseconds you pass in as one-way latency to and from the server. In production, session replays of LiveView apps on slow connections show the same problem from the user’s side: input that lags behind typing, and a flash when the page disconnects and reconnects.

LiveView has two more costs. Offline support is hard because events need a live server connection. You also have to run Elixir in production, with one process per connected page.

SituationBetter fitWhy
CRUD screens, dashboards, forms, admin toolsLiveViewNo separate API layer; state and rendering stay on the server
Offline-first or poor-connectivity appsReact / VueLiveView needs a live connection to handle events
Heavy client-side interaction (editors, canvas, drag-heavy UIs)React / VueEach interaction costs a round trip; hooks help but don’t replace a client framework
Team with no appetite for ElixirReact / VueYou need to run and maintain Elixir on the server

For a broader view of the trade-off between server-driven and client-driven UI, see this comparison of htmx with Vue and React.

Conclusion

LiveView is a good fit for CRUD-style apps that would otherwise need a JSON API and a client-side store. You write callbacks in one module, the server holds the state, and hooks or JS commands cover what has to run in the browser. A good next step is to generate a Phoenix 1.8 app, rebuild one form from your current SPA as a LiveView, and turn on the latency simulator to see how it behaves on a slow connection before you commit.

FAQs

What happens when a LiveView loses its WebSocket connection?

The client reconnects automatically with exponential back-off, and mount/3 runs again, so any assigns not rebuilt in mount/3 are lost. Forms that have both an id attribute and a phx-change binding recover automatically: after reconnecting, LiveView resends the current form values through the phx-change event. Add phx-auto-recover to trigger a different recovery event, or set it to 'ignore' to turn recovery off.

How do I stop code in mount/3 from running twice?

Wrap it in a connected?(socket) check. Phoenix.LiveView.connected?/1 returns false during the static HTTP render and true once the WebSocket has connected. Subscriptions, timers and expensive queries inside an if connected?(socket) block therefore run only in the live process. Keep cheap default assigns outside the check so the static render still produces complete, indexable HTML.

How do I test a LiveView without a browser?

Use Phoenix.LiveViewTest, which ships with LiveView and runs under ExUnit. Call live(conn, path) to mount the view and get a view handle, then drive it with helpers such as render_click and render_change and assert on the HTML they return. These tests talk to the LiveView process directly, so client-side hook JavaScript does not run in them.

What is the difference between a function component and a LiveComponent?

A function component is a stateless function that takes assigns and returns HEEx, so events from its markup go to the parent LiveView. A LiveComponent, defined with use Phoenix.LiveComponent, keeps its own state and handles its own events when its markup sets phx-target={@myself}. Both run inside the parent LiveView process. Use function components by default, and choose a LiveComponent only when you need state and event handling kept inside the component.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.