Phoenix LiveView Basics

LiveView, in the words of the official documentation, "provides rich, real-time user experiences with server-rendered HTML". In plain English: you can build interactive UIs, the kind you would usually need React or Vue for, without writing any JavaScript yourself. With Phoenix 1.8 and LiveView 1.1, this approach has matured into a production-ready way to ship interactive apps quickly.

Feel free to skip this chapter if real-time UIs are not on your radar yet. You can come back any time, nothing later in the book depends on it.

Under the hood, LiveView keeps a WebSocket connection open between the browser and the server. There is still a small amount of JavaScript, bundled by Phoenix itself, but you do not have to write or maintain it.

A small but important detail: the first response is always plain server-rendered HTML. Users see the full page immediately, search engines see it, and only afterwards does LiveView upgrade the page to an interactive session over the WebSocket. Best of both worlds.

In this chapter, we build four progressively richer examples: a light switch, a ticking clock, a counter, and an airport code search.

Light Switch

The light switch is the "Hello World!" of LiveView.

Credit to Pragmatic Studio’s excellent LiveView course (https://online.pragmaticstudio.com/courses/liveview) for this idea.

Create a fresh Phoenix application:

$ mix phx.new demo --no-ecto (1)
[...]
Fetch and install dependencies? [Yn] Y (2)
[...]

    $ cd demo

Start your Phoenix app with:

    $ mix phx.server
1 Phoenix 1.8 removed the --live flag: LiveView is always available in new projects. You only need --no-ecto because we do not want a database for this example. Phoenix ships Tailwind CSS and daisyUI out of the box, so we have nice buttons without adding anything.
2 Say Y. The first install takes a moment, subsequent ones are cached.

Our goal: a page at /light that shows the status of a virtual light bulb and lets the user toggle it on and off, with no page reload and no JavaScript in sight.

Step 1: add a LiveView route

lib/demo_web/router.ex
  scope "/", DemoWeb do
    pipe_through :browser

    get "/", PageController, :home
    live "/light", LightLive (1)
  end
1 live "/light", LightLive registers a LiveView-powered route. No controller needed, the LiveView module handles both the initial render and subsequent interactions.

Step 2: create the LiveView module

LiveView modules are conventionally placed under lib/demo_web/live/.

lib/demo_web/live/light_live.ex
defmodule DemoWeb.LightLive do
  use DemoWeb, :live_view

  def render(assigns) do (1)
    ~H"""
    <h1 class="text-2xl font-bold mb-4">The light is off.</h1>
    """
  end
end
1 render/1 returns a HEEx template via the ~H sigil. HEEx replaces the old .eex sigil ~L that was used in early LiveView versions; you should always use ~H today.

Open http://localhost:4000/light in your browser.

http://localhost:4000/light

Step 3: move the word "off" into an assign

Hard-coding the word "off" is no fun. We want the template to display a variable we can change later. To do that we need mount/3, the function LiveView calls when a session starts:

lib/demo_web/live/light_live.ex
defmodule DemoWeb.LightLive do
  use DemoWeb, :live_view

  def mount(_params, _session, socket) do (1)
    {:ok, assign(socket, :light_bulb_status, "off")} (2)
  end

  def render(assigns) do
    ~H"""
    <h1 class="text-2xl font-bold mb-4">The light is {@light_bulb_status}.</h1> (3)
    """
  end
end
1 mount/3 runs once when the LiveView starts. For this example we only care about the third argument, socket, which is the LiveView equivalent of conn.
2 assign/3 stores values on the socket. The template can read them back with @name.
3 HEEx interpolates {@light_bulb_status} into the HTML. That {…} syntax replaces the older <%= @light_bulb_status %> and is the recommended form in LiveView 1.1.

The page still reads "The light is off" but now off is dynamic. Good.

Step 4: add an "On" button

We want a button that turns the light on. In LiveView, any DOM event can be wired back to the server with a phx-* attribute:

def render(assigns) do
  ~H"""
  <h1 class="text-2xl font-bold mb-4">The light is {@light_bulb_status}.</h1>
  <button type="button" phx-click="on" class="btn btn-success"> (1)
    On
  </button>
  """
end
1 phx-click="on" tells LiveView: when the user clicks this button, send an event named "on" to the server.
http://localhost:4000/light

The button appears, but clicking it does nothing yet. LiveView needs a handle_event/3 to know what to do with the "on" message.

lib/demo_web/live/light_live.ex
defmodule DemoWeb.LightLive do
  use DemoWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, assign(socket, :light_bulb_status, "off")}
  end

  def render(assigns) do
    ~H"""
    <h1 class="text-2xl font-bold mb-4">The light is {@light_bulb_status}.</h1>
    <button type="button" phx-click="on" class="btn btn-success">
      On
    </button>
    """
  end

  def handle_event("on", _params, socket) do (1)
    {:noreply, assign(socket, :light_bulb_status, "on")} (2)
  end
end
1 The first argument pattern-matches the event name sent by the browser; _params swallows any extra payload we do not need.
2 Update the assign and return the new socket. The return tuple is {:noreply, socket} because we are not redirecting.

Click the button and the headline changes to "The light is on" instantly, no page reload.

http://localhost:4000/light

Step 5: add an "Off" button

Mirror the on flow for the opposite direction:

lib/demo_web/live/light_live.ex
defmodule DemoWeb.LightLive do
  use DemoWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, assign(socket, :light_bulb_status, "off")}
  end

  def render(assigns) do
    ~H"""
    <h1 class="text-2xl font-bold mb-4">The light is {@light_bulb_status}.</h1>

    <button
      type="button"
      phx-click="on"
      class="btn btn-success mr-2"
      disabled={@light_bulb_status == "on"}> (1)
      On
    </button>

    <button
      type="button"
      phx-click="off"
      class="btn btn-neutral"
      disabled={@light_bulb_status == "off"}>
      Off
    </button>
    """
  end

  def handle_event("on", _params, socket) do
    {:noreply, assign(socket, :light_bulb_status, "on")}
  end

  def handle_event("off", _params, socket) do
    {:noreply, assign(socket, :light_bulb_status, "off")}
  end
end
1 HEEx treats {expr} inside an attribute as an Elixir expression. The button is disabled when it does not apply to the current state, a small UX polish that LiveView makes trivial to express.
http://localhost:4000/light

Click away. The light turns on and off, the button enables/disables appropriately, and we have not written a single line of JavaScript.

Clock

Events from the user are only half the story. LiveView can also push updates on its own schedule. Here is a server-driven clock: the browser receives a new time every second without asking.

$ mix phx.new clock --no-ecto
[...]
$ cd clock
lib/clock_web/router.ex
  scope "/", ClockWeb do
    pipe_through :browser

    get "/", PageController, :home
    live "/clock", ClockLive (1)
  end
1 The clock will live at http://localhost:4000/clock.
lib/clock_web/live/clock_live.ex
defmodule ClockWeb.ClockLive do
  use ClockWeb, :live_view

  def mount(_params, _session, socket) do
    if connected?(socket) do (1)
      :timer.send_interval(1000, self(), :tick) (2)
    end

    {:ok, assign_current_time(socket)} (3)
  end

  def render(assigns) do
    ~H"""
    <h1 class="text-4xl font-mono">{@now}</h1>
    """
  end

  def handle_info(:tick, socket) do (4)
    {:noreply, assign_current_time(socket)}
  end

  defp assign_current_time(socket) do
    now =
      Time.utc_now() (5)
      |> Time.to_string()
      |> String.split(".") (6)
      |> hd()

    assign(socket, now: now) (7)
  end
end
1 mount/3 runs twice: first on the initial HTTP request (so users without JavaScript still see a page), and again after the WebSocket connects. connected?/1 is true only on the second call. We start the timer there so we do not schedule two timers per page load.
2 The Erlang :timer module sends :tick to the current process every 1000 ms.
3 Seed the socket with a current time so the initial render is not blank.
4 handle_info/2 receives messages sent to the LiveView process. Here it is the :tick atom from the timer.
5 Time.utc_now/0 returns the server time.
6 String.split("…", ".") |> hd() drops the microseconds tail so the display is 12:34:56, not 12:34:56.789012.
7 Remember to return the socket, otherwise the template will not see the new value.

Start the server with mix phx.server and open http://localhost:4000/clock. The time ticks once per second.

http://localhost:4000/clock

Counter

A counter is a classic interactive widget. It starts at 0 and a button increases it by one.

$ mix phx.new demo --no-ecto
[...]
$ cd demo
lib/demo_web/router.ex
  scope "/", DemoWeb do
    pipe_through :browser

    get "/", PageController, :home
    live "/counter", CounterLive (1)
  end
1 The counter will be available at http://localhost:4000/counter.

Create lib/demo_web/live/counter_live.ex:

lib/demo_web/live/counter_live.ex
defmodule DemoWeb.CounterLive do
  use DemoWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, assign(socket, :counter, 0)} (1)
  end

  def render(assigns) do
    ~H"""
    <h1 class="text-2xl font-bold mb-4">Current count: {@counter}</h1>
    <button phx-click="inc" class="btn btn-primary mr-2">+1</button> (2)
    <button phx-click="reset" class="btn">Reset</button>
    """
  end

  def handle_event("inc", _params, socket) do
    {:noreply, update(socket, :counter, &(&1 + 1))} (3)
  end

  def handle_event("reset", _params, socket) do
    {:noreply, assign(socket, :counter, 0)}
  end
end
1 Start the counter at 0.
2 Two buttons, two events ("inc" and "reset").
3 update/3 is a shortcut: "update this assign by passing it through this function." The capture &(&1 + 1) is shorthand for fn n → n + 1 end.

Open http://localhost:4000/counter and click the buttons.

http://localhost:4000/counter

assign vs update

In the counter example we used update/3:

def handle_event("inc", _params, socket) do
  {:noreply, update(socket, :counter, &(&1 + 1))}
end

The same thing with assign/3 looks like:

def handle_event("inc", _params, socket) do
  counter = socket.assigns.counter + 1
  {:noreply, assign(socket, :counter, counter)}
end

Both work. Reach for update/3 when the new value depends on the old one, and assign/3 when you are setting a value outright.

Our last example is an airport code search. User types a code prefix, the list of matching airports updates live.

$ mix phx.new travelagent --no-ecto
$ cd travelagent

Route:

lib/travelagent_web/router.ex
  scope "/", TravelagentWeb do
    pipe_through :browser

    get "/", PageController, :home
    live "/search", SearchLive
  end

A small module that pretends to be our airports database:

lib/travelagent/airports.ex
defmodule Travelagent.Airports do
  def search_by_code(""), do: [] (1)

  def search_by_code(code) do (2)
    list_airports()
    |> Enum.filter(&String.starts_with?(&1.code, code))
  end

  def list_airports do (3)
    [
      %{name: "Berlin Brandenburg", code: "BER"},
      %{name: "Bremen", code: "BRE"},
      %{name: "Köln/Bonn", code: "CGN"},
      %{name: "Dortmund", code: "DTM"},
      %{name: "Dresden", code: "DRS"},
      %{name: "Düsseldorf", code: "DUS"},
      %{name: "Frankfurt", code: "FRA"},
      %{name: "Frankfurt-Hahn", code: "HHN"},
      %{name: "Hamburg", code: "HAM"},
      %{name: "Hannover", code: "HAJ"},
      %{name: "Leipzig Halle", code: "LEJ"},
      %{name: "München", code: "MUC"},
      %{name: "Münster Osnabrück", code: "FMO"},
      %{name: "Nürnberg", code: "NUE"},
      %{name: "Paderborn Lippstadt", code: "PAD"},
      %{name: "Stuttgart", code: "STR"}
    ]
  end
end
1 An empty query produces an empty list, saves us a branch later.
2 search_by_code/1 returns only the airports whose codes start with the given prefix.
3 Hard-coded list for demo purposes. A real app would read from a database.

This time the template lives in its own .heex file alongside the LiveView module. LiveView automatically picks up <module_name>.html.heex next to the module file:

lib/travelagent_web/live/search_live.html.heex
<div class="max-w-2xl mx-auto p-4">
  <form phx-submit="airport_code_search" class="mb-6 space-y-2">
    <label for="airport_code" class="label">Airport Code</label>
    <input
      id="airport_code"
      type="text"
      name="airport_code"
      value={@airport_code}
      placeholder="e.g. FRA"
      autofocus
      autocomplete="off"
      class="input input-bordered w-full" /> (1)
    <button type="submit" class="btn btn-primary">Search</button>
  </form>

  <%= unless @airports == [] do %> (2)
    <h2 class="text-xl font-semibold mb-2">Search Results</h2>
    <table class="table table-zebra">
      <thead>
        <tr>
          <th>Code</th>
          <th>Name</th>
        </tr>
      </thead>
      <tbody>
        <tr :for={airport <- @airports}> (3)
          <td class="font-mono">{airport.code}</td>
          <td>{airport.name}</td>
        </tr>
      </tbody>
    </table>
  <% end %>
</div>
1 autofocus puts the cursor in the field on page load; autocomplete="off" stops the browser from pre-filling values that would fight our LiveView updates.
2 unless + end still works in HEEx but feels clunky. We could also have written <%= if @airports != [], do: … %>.
3 LiveView 1.1 supports the :for attribute directly on HTML tags. It replaces the older <%= for airport ← @airports do %>…​<% end %> block and is the recommended way to loop in HEEx.

Finally, the LiveView module:

lib/travelagent_web/live/search_live.ex
defmodule TravelagentWeb.SearchLive do
  use TravelagentWeb, :live_view
  alias Travelagent.Airports (1)

  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign(:airport_code, "") (2)
      |> assign(:airports, [])

    {:ok, socket}
  end

  def handle_event(
        "airport_code_search",
        %{"airport_code" => airport_code},
        socket
      ) do
    airport_code = String.upcase(airport_code) (3)

    socket =
      socket
      |> assign(:airport_code, airport_code) (4)
      |> assign(:airports, Airports.search_by_code(airport_code))

    {:noreply, socket}
  end
end
1 alias shortens Travelagent.Airports to Airports in this module.
2 Start with empty inputs so the template has something to show.
3 Normalise the query to upper-case so fra matches FRA.
4 Send the (normalised) text back to the input via the assign. Now the user sees the code they are actually searching for.

Open http://localhost:4000/search and try it out.

http://localhost:4000/search

Autocomplete

Pressing the Search button for every query is 2005. Let us update the result list on every keystroke instead. The only change is in the template:

lib/travelagent_web/live/search_live.html.heex
<div class="max-w-2xl mx-auto p-4">
  <form phx-change="airport_code_search" class="mb-6 space-y-2"> (1)
    <label for="airport_code" class="label">Airport Code</label>
    <input
      id="airport_code"
      type="text"
      name="airport_code"
      value={@airport_code}
      placeholder="e.g. FRA"
      autofocus
      autocomplete="off"
      class="input input-bordered w-full" />
  </form>

  <%= unless @airports == [] do %>
    <h2 class="text-xl font-semibold mb-2">Search Results</h2>
    <table class="table table-zebra">
      <thead>
        <tr>
          <th>Code</th>
          <th>Name</th>
        </tr>
      </thead>
      <tbody>
        <tr :for={airport <- @airports}>
          <td class="font-mono">{airport.code}</td>
          <td>{airport.name}</td>
        </tr>
      </tbody>
    </table>
  <% end %>
</div>
1 Swap phx-submit for phx-change. Every keystroke now fires the airport_code_search event, and because our handler already accepts the full form data, there is nothing else to change.

Open http://localhost:4000/search and start typing. The list updates as you type.

http://localhost:4000/search

Forms

The airport search already used a form (phx-submit / phx-change), but we only ever passed one field around and we read it with value={@airport_code} by hand. Real forms have multiple fields, per-field errors, and need to survive a failed submit without losing the user’s input. LiveView has a dedicated helper for that: Phoenix.Component.to_form/2.

The pattern is:

  1. Build a form struct with to_form(params, as: "profile").

  2. Render it with <.form for={@form}> and reference fields as @form[:name].

  3. Handle phx-change to re-validate on every keystroke, and phx-submit to actually save.

Route:

lib/demo_web/router.ex
  scope "/", DemoWeb do
    pipe_through :browser

    get "/", PageController, :home
    live "/profile", ProfileLive (1)
  end
1 The profile route points at the new ProfileLive module.

The LiveView itself. This example uses a plain map for validation so we do not pull Ecto into the project; with Ecto you would wrap the same shape in a changeset.

lib/demo_web/live/profile_live.ex
defmodule DemoWeb.ProfileLive do
  use DemoWeb, :live_view

  def mount(_params, _session, socket) do
    form = to_form(%{"name" => "", "email" => ""}, as: "profile") (1)
    {:ok, assign(socket, form: form, errors: %{}, saved: nil)}
  end

  def render(assigns) do
    ~H"""
    <div class="max-w-md mx-auto p-4 space-y-4">
      <h1 class="text-2xl font-bold">Edit profile</h1>

      <.form
        for={@form}
        phx-change="validate"
        phx-submit="save"
        class="space-y-4"
      >
        <div>
          <label for={@form[:name].id} class="label">Name</label> (2)
          <input
            type="text"
            name={@form[:name].name}
            id={@form[:name].id}
            value={@form[:name].value}
            class="input input-bordered w-full"
          />
          <p :if={@errors[:name]} class="text-error text-sm">
            {@errors[:name]}
          </p>
        </div>

        <div>
          <label for={@form[:email].id} class="label">Email</label>
          <input
            type="text"
            name={@form[:email].name}
            id={@form[:email].id}
            value={@form[:email].value}
            class="input input-bordered w-full"
          />
          <p :if={@errors[:email]} class="text-error text-sm">
            {@errors[:email]}
          </p>
        </div>

        <button type="submit" class="btn btn-primary">Save</button>
      </.form>

      <p :if={@saved} class="text-success">
        Saved: {@saved["name"]} &lt;{@saved["email"]}&gt;
      </p>
    </div>
    """
  end

  def handle_event("validate", %{"profile" => params}, socket) do (3)
    {:noreply,
     assign(socket,
       form: to_form(params, as: "profile"),
       errors: validate(params),
       saved: nil
     )}
  end

  def handle_event("save", %{"profile" => params}, socket) do (4)
    case validate(params) do
      errors when errors == %{} ->
        {:noreply,
         assign(socket,
           form: to_form(params, as: "profile"),
           errors: %{},
           saved: params
         )}

      errors ->
        {:noreply,
         assign(socket,
           form: to_form(params, as: "profile"),
           errors: errors,
           saved: nil
         )}
    end
  end

  defp validate(params) do
    %{}
    |> maybe_put_error(:name, params, &(String.length(&1) < 2), "at least 2 characters")
    |> maybe_put_error(:email, params, &(not String.contains?(&1, "@")), "must contain @")
  end

  defp maybe_put_error(errors, field, params, pred?, message) do
    value = params[Atom.to_string(field)] || ""
    if pred?.(value), do: Map.put(errors, field, message), else: errors
  end
end
1 to_form/2 turns a params map (or an Ecto changeset) into a Phoenix.HTML.Form struct. The as: option is the form name and becomes the top-level key in the event payload ("profile" ⇒ %{…​}).
2 @form[:name] returns a field struct with .id, .name, and .value pre-computed. No more hand-wiring name="profile[name]".
3 phx-change fires on every keystroke. Re-assigning form: with the latest params keeps what the user typed; re-computing errors keeps the UI in sync.
4 phx-submit runs once on submit. In a real app this would insert a row via Ecto or an Ash action.
http://localhost:4000/profile

Type an invalid Name ("A") and an invalid Email ("no-at-sign"): errors appear live, driven by phx-change:

http://localhost:4000/profile
The <.form> component also ships with built-in CSRF protection (it injects a hidden token when the form targets a controller). For LiveView forms you rarely need to think about it — LiveView carries the socket’s CSRF token implicitly.

With Ecto or Ash

Two common sources for to_form/2:

  • Ecto.Changeset. to_form(changeset) — field errors come straight from the changeset, so @form[:email].errors replaces the manual @errors[:email] map above. Phoenix’s built-in generators use this shape.

  • AshPhoenix.Form. AshPhoenix.Form.for_create(App.Blog.Post, :create, as: "post") |> to_form() — Ash actions plug directly into LiveView forms. Field validation, relationship handling, and typed arguments all come along automatically. See https://hexdocs.pm/ash_phoenix for the full DSL.

Streams

By now you might have noticed that every LiveView example keeps the full state of the page in socket.assigns: @light_bulb_status, @counter, the whole @airports list. For short lists this is fine — LiveView’s diffing only sends the bits that changed over the wire.

When the list grows to hundreds or thousands of items, re-rendering and re-diffing the whole assign on every update starts to hurt. Streams are LiveView 1.0’s answer: the server never holds the full list, it just emits insert / update / delete operations and the browser reconciles the DOM.

The pattern:

  1. Seed the stream in mount/3 with stream/3.

  2. Render it inside an id=…​ container with phx-update="stream".

  3. Use stream_insert/4, stream_delete/3, and friends in your handle_event/3 callbacks.

Route and LiveView:

lib/demo_web/router.ex
  scope "/", DemoWeb do
    pipe_through :browser

    live "/todos", TodoLive
  end
lib/demo_web/live/todo_live.ex
defmodule DemoWeb.TodoLive do
  use DemoWeb, :live_view

  def mount(_params, _session, socket) do
    seed = [
      %{id: 1, text: "Read the LiveView streams docs"},
      %{id: 2, text: "Fix the colour picker"},
      %{id: 3, text: "Ship the landing page"}
    ]

    {:ok,
     socket
     |> assign(:form, to_form(%{"text" => ""}, as: "todo"))
     |> assign(:next_id, 4)
     |> stream(:todos, seed)} (1)
  end

  def render(assigns) do
    ~H"""
    <div class="max-w-md mx-auto p-4 space-y-4">
      <h1 class="text-2xl font-bold">Todos</h1>

      <.form for={@form} phx-submit="add" class="flex gap-2">
        <input
          type="text"
          name="todo[text]"
          value=""
          placeholder="What needs doing?"
          class="input input-bordered flex-1"
        />
        <button type="submit" class="btn btn-primary">Add</button>
      </.form>

      <ul id="todos" phx-update="stream" class="space-y-1"> (2)
        <li
          :for={{dom_id, todo} <- @streams.todos} (3)
          id={dom_id}
          class="flex items-center justify-between p-2 border rounded"
        >
          <span>{todo.text}</span>
          <button
            type="button"
            phx-click="remove"
            phx-value-id={todo.id}
            class="btn btn-sm btn-ghost"
          >
            ✕
          </button>
        </li>
      </ul>
    </div>
    """
  end

  def handle_event("add", %{"todo" => %{"text" => text}}, socket)
      when text != "" do
    todo = %{id: socket.assigns.next_id, text: text}

    {:noreply,
     socket
     |> stream_insert(:todos, todo, at: 0) (4)
     |> assign(:next_id, socket.assigns.next_id + 1)}
  end

  def handle_event("add", _params, socket), do: {:noreply, socket}

  def handle_event("remove", %{"id" => id}, socket) do
    {:noreply, stream_delete(socket, :todos, %{id: String.to_integer(id)})} (5)
  end
end
1 stream(:todos, seed) names the stream and seeds it with the initial items. Each item must have an :id key so LiveView can track it in the DOM.
2 phx-update="stream" is the magic attribute. Without it the template would still render, but LiveView would treat the list as ordinary assigns and you would lose the memory win.
3 Iterating over @streams.todos yields {dom_id, todo} tuples. The dom_id is what LiveView uses to target DOM nodes for updates; put it on the element’s id attribute.
4 stream_insert(socket, :todos, todo, at: 0) prepends. Pass at: -1 to append, or omit at: for append-to-end.
5 stream_delete(socket, :todos, %{id: …​}) removes the matching item. The struct-or-map you pass only needs to expose the same key as the seed — here :id.
http://localhost:4000/todos

Add a todo, remove one. The page updates instantly, and — critically — socket.assigns.streams.todos does not grow with each insert. LiveView keeps only the delta it needs to ship to the client.

For datasets large enough that you do not want to load them all into memory at once, combine stream/3 with Phoenix.LiveView.stream_configure/3 and a paged read. That is outside this chapter; the official stream/3 docs are excellent.