Skip to main content
Technology & EngineeringElixir Phoenix214 lines

Phoenix Liveview

Phoenix LiveView patterns for building real-time, server-rendered interactive UIs

Quick Summary18 lines
You are an expert in Phoenix LiveView for building real-time interactive user interfaces in Elixir.

## Key Points

- **LiveView**: A process that holds state (`assigns`), renders HTML via HEEx templates, and handles events over a WebSocket.
- **Lifecycle**: `mount/3` -> `handle_params/3` -> `render/1`. Events trigger `handle_event/3`.
- **Assigns**: The state map available in templates via `@assign_name`.
- **HEEx Templates**: HTML + Elixir expressions with compile-time validation.
- **LiveComponents**: Stateful or stateless components that encapsulate markup and behaviour within a LiveView.
- **Streams**: Efficient handling of large collections without holding all items in assigns.
- Use `streams` for lists that can grow large — they avoid keeping the full collection in assigns.
- Subscribe to PubSub topics only when `connected?(socket)` is true to avoid duplicate subscriptions during the static render.
- Minimize assigns — every assign change triggers a re-render diff. Use `assign_new/3` for values that should only be set once.
- Break complex UIs into LiveComponents. Use stateless function components for pure markup, stateful LiveComponents when the component needs its own event handling.
- Use `push_event/3` to send data to JavaScript hooks for things LiveView cannot handle natively (e.g., clipboard, canvas, third-party JS libraries).
- Debounce form validation with `phx-debounce` to reduce server round-trips.
skilldb get elixir-phoenix-skills/Phoenix LiveviewFull skill: 214 lines
Paste into your CLAUDE.md or agent config

Phoenix LiveView — Elixir/Phoenix

You are an expert in Phoenix LiveView for building real-time interactive user interfaces in Elixir.

Overview

Phoenix LiveView enables rich, real-time UIs with server-rendered HTML. It maintains a persistent WebSocket connection between client and server, sending minimal diffs to update the DOM. This eliminates most client-side JavaScript while delivering highly interactive experiences.

Core Philosophy

LiveView inverts the traditional web architecture by keeping the UI state on the server. Each connected user has a dedicated server process that holds the current state (assigns), renders HTML, and sends minimal diffs over a WebSocket when state changes. This eliminates the need for a separate API layer, client-side state management, and the inevitable synchronization bugs that come with duplicating logic across frontend and backend.

The tradeoff is explicit and intentional: every user interaction requires a server round-trip. LiveView optimizes this by sending only the parts of the HTML that actually changed (not the full page), making these updates fast enough to feel instantaneous for most interactions. The programming model is simple — update assigns in an event handler, and the framework handles re-rendering and DOM patching automatically.

Streams represent LiveView's answer to the problem of large collections. Instead of holding thousands of items in assigns (which would be diffed on every render), streams manage client-side DOM elements efficiently with insert, update, and delete operations. This pattern is essential for feeds, tables, and any UI that displays a growing or changing list of items.

Anti-Patterns

  • Assigns as Global State: Storing every piece of data the page might need in assigns during mount/3. This bloats the process memory, slows down diffs, and forces unnecessary re-renders. Use assign_new/3 for values computed once, streams for collections, and assign_async/3 for data fetched after initial render.

  • Expensive Mount Queries: Running slow database queries or external API calls in mount/3 without caching or async loading. Mount runs on every page load and every WebSocket reconnect. Use assign_async/3 to load slow data without blocking the initial render.

  • Component Data Over-Sharing: Passing the entire socket or parent assigns into a LiveComponent instead of only the specific data it needs. This creates invisible coupling and causes unnecessary re-renders when unrelated assigns change.

  • JavaScript as Escape Hatch: Reaching for JavaScript hooks for interactions that LiveView handles natively (form validation, dynamic UI updates, conditional display). Hooks add complexity and bypass LiveView's diff engine. Reserve them for browser APIs that LiveView genuinely cannot access (clipboard, canvas, geolocation).

  • Ignoring the Static Render: Building LiveViews that only work when the WebSocket is connected. The first render is a regular HTTP response. If your mount/3 skips data loading when connected?/1 is false, users see a blank or broken page before the WebSocket connects.

Core Concepts

  • LiveView: A process that holds state (assigns), renders HTML via HEEx templates, and handles events over a WebSocket.
  • Lifecycle: mount/3 -> handle_params/3 -> render/1. Events trigger handle_event/3.
  • Assigns: The state map available in templates via @assign_name.
  • HEEx Templates: HTML + Elixir expressions with compile-time validation.
  • LiveComponents: Stateful or stateless components that encapsulate markup and behaviour within a LiveView.
  • Streams: Efficient handling of large collections without holding all items in assigns.

Implementation Patterns

Basic LiveView

defmodule MyAppWeb.CounterLive do
  use MyAppWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, count: 0)}
  end

  @impl true
  def handle_event("increment", _params, socket) do
    {:noreply, update(socket, :count, &(&1 + 1))}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div>
      <h1>Count: {@count}</h1>
      <button phx-click="increment">+1</button>
    </div>
    """
  end
end

LiveView with Streams

defmodule MyAppWeb.TimelineLive do
  use MyAppWeb, :live_view

  alias MyApp.Posts

  @impl true
  def mount(_params, _session, socket) do
    posts = Posts.list_recent(limit: 50)
    {:ok, stream(socket, :posts, posts)}
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    post = Posts.get_post!(id)
    {:ok, _} = Posts.delete_post(post)
    {:noreply, stream_delete(socket, :posts, post)}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div id="posts" phx-update="stream">
      <div :for={{dom_id, post} <- @streams.posts} id={dom_id}>
        <p>{post.body}</p>
        <button phx-click="delete" phx-value-id={post.id}>Delete</button>
      </div>
    </div>
    """
  end
end

Stateful LiveComponent

defmodule MyAppWeb.FormComponent do
  use MyAppWeb, :live_component

  @impl true
  def update(%{item: item} = assigns, socket) do
    changeset = MyApp.Catalog.change_item(item)

    {:ok,
     socket
     |> assign(assigns)
     |> assign_form(changeset)}
  end

  @impl true
  def handle_event("validate", %{"item" => params}, socket) do
    changeset =
      socket.assigns.item
      |> MyApp.Catalog.change_item(params)
      |> Map.put(:action, :validate)

    {:noreply, assign_form(socket, changeset)}
  end

  @impl true
  def handle_event("save", %{"item" => params}, socket) do
    case MyApp.Catalog.create_item(params) do
      {:ok, item} ->
        notify_parent({:saved, item})
        {:noreply, push_navigate(socket, to: socket.assigns.patch)}

      {:error, changeset} ->
        {:noreply, assign_form(socket, changeset)}
    end
  end

  defp assign_form(socket, changeset) do
    assign(socket, :form, to_form(changeset))
  end

  defp notify_parent(msg), do: send(self(), {__MODULE__, msg})

  @impl true
  def render(assigns) do
    ~H"""
    <div>
      <.form for={@form} phx-target={@myself} phx-change="validate" phx-submit="save">
        <.input field={@form[:name]} label="Name" />
        <.button>Save</.button>
      </.form>
    </div>
    """
  end
end

Real-Time Updates with PubSub

defmodule MyAppWeb.DashboardLive do
  use MyAppWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    if connected?(socket) do
      Phoenix.PubSub.subscribe(MyApp.PubSub, "metrics")
    end

    {:ok, assign(socket, cpu: 0, memory: 0)}
  end

  @impl true
  def handle_info({:metrics_update, metrics}, socket) do
    {:noreply, assign(socket, cpu: metrics.cpu, memory: metrics.memory)}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div>
      <p>CPU: {@cpu}%</p>
      <p>Memory: {@memory} MB</p>
    </div>
    """
  end
end

Best Practices

  • Use streams for lists that can grow large — they avoid keeping the full collection in assigns.
  • Subscribe to PubSub topics only when connected?(socket) is true to avoid duplicate subscriptions during the static render.
  • Minimize assigns — every assign change triggers a re-render diff. Use assign_new/3 for values that should only be set once.
  • Break complex UIs into LiveComponents. Use stateless function components for pure markup, stateful LiveComponents when the component needs its own event handling.
  • Use push_event/3 to send data to JavaScript hooks for things LiveView cannot handle natively (e.g., clipboard, canvas, third-party JS libraries).
  • Debounce form validation with phx-debounce to reduce server round-trips.

Common Pitfalls

  • Putting expensive queries in mount: This runs on every page load and reconnect. Cache results or use assign_async/3 for slow data fetches.
  • Forgetting the static render: The first render is a regular HTTP response without WebSocket. Code that relies on connected?/1 being true will not run during this phase.
  • Leaking assigns into components: Pass only the assigns a component needs. Avoid passing the entire socket or parent assigns.
  • Not handling phx-disconnected state: Users see a stale page when the WebSocket drops. Use the built-in connection status UI or add custom handling.
  • Large payloads in assigns: Binary data or deeply nested structures bloat the diff. Store large data in ETS or the database and pass only identifiers.

Install this skill directly: skilldb add elixir-phoenix-skills

Get CLI access →