In Book 4, the request process rendered and exited. LiveView asks: what if it didn't exit? Keep a process alive per browser tab, connect it over a WebSocket, hold UI state in the process, re-render on events, and ship minimal diffs to the DOM. The result: rich, real-time interfaces written entirely in server-side Elixir β and it's all GenServer machinery you already know.
book5-liveview.livemd β run actual LiveViews from the notebook via Phoenix Playground: a counter, a validated form, and a multi-tab synced dashboard with PubSub. Open two browser tabs and watch them sync.A LiveView is a process (built on GenServer) whose state is your UI state (assigns) and whose "render" is a function of that state. The correspondence is exact:
| GenServer (Book 2) | LiveView | React (for orientation) |
|---|---|---|
init/1 | mount/3 | constructor + initial state |
| state | socket.assigns | useState values |
handle_call/cast | handle_event/3 (user events) | event handlers |
handle_info/2 | handle_info/2 (PubSub, timers) | subscriptions/effects |
| β | render/1 (HEEx, auto-called after changes) | the render function |
A complete LiveView:
defmodule CounterLive do
use Phoenix.LiveView
def mount(_params, _session, socket) do
{:ok, assign(socket, count: 0)}
end
def handle_event("inc", _params, socket) do
{:noreply, update(socket, :count, &(&1 + 1))}
end
def render(assigns) do
~H"""
<h1>Count: {@count}</h1>
<button phx-click="inc">+</button>
"""
end
end
No controller, no API endpoint, no fetch, no client state, no JSON serialization. phx-click="inc" sends an event over the WebSocket; handle_event updates assigns; the changed part of the DOM updates. ~15 lines for a working reactive UI.
CounterLive β watch what crosses the wireA LiveView page loads in two phases β this trips everyone up once, so let's make it interactive:
Consequences you must know: mount/3 runs twice (once for the SEO-friendly static render over HTTP, once when the socket connects and the real process starts). Guard expensive work with connected?(socket) β subscribe to PubSub and start timers only in the connected mount. If the process crashes, the client automatically reconnects and mount runs again: your supervisor-restart story from Book 2, now with automatic client recovery.
:tick timer in mount/3 unconditionally. What's the bug?if connected?(socket), do: :timer.send_interval(1000, :tick).The client side is declarative attributes; the server side is handle_event/3 clauses (pattern matching again):
| Binding | Fires | Handled by |
|---|---|---|
phx-click="save" | on click (value via phx-value-* attrs) | handle_event("save", params, socket) |
phx-change="validate" | form input changes (debounced with phx-debounce) | live validation β changesets shine here |
phx-submit="create" | form submit | the actual write |
phx-keydown, phx-blur, phx-focus⦠| what you'd expect | more handle_event clauses |
# The canonical live form β validate as they type, save on submit:
def handle_event("validate", %{"product" => params}, socket) do
changeset = Product.changeset(%Product{}, params)
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
def handle_event("create", %{"product" => params}, socket) do
case Shop.create_product(params) do
{:ok, _} -> {:noreply, socket |> put_flash(:info, "created!") |> push_navigate(to: "/products")}
{:error, cs} -> {:noreply, assign(socket, form: to_form(cs))}
end
end
Note what's absent: no client validation library, no duplicated rules β the same Ecto changeset from Book 4 drives live form errors.
HEEx templates compile to code that knows exactly which DOM parts depend on which assigns. When @count changes, LiveView doesn't re-render and re-send the page β it sends something like {"3": "42"}: slot 3's new value, a few bytes. The client patches the DOM (morphdom-style, like a server-side virtual DOM where the diffing happened at compile time).
assign/update β they mark what's dirty. (2) Keep assigns minimal: derive in render, don't precompute variants. (3) In templates, access data through @assigns so change-tracking sees it. (4) For big collections, use streams (below) so the server doesn't hold the whole list in memory.Function components are stateless partials: a function taking assigns, returning HEEx. Declared attributes are compile-time checked:
attr :label, :string, required: true
attr :count, :integer, default: 0
slot :inner_block
def stat_card(assigns) do
~H"""
<div class="card">
<h3>{@label}</h3><p>{@count}</p>
{render_slot(@inner_block)}
</div>
"""
end
# usage: <.stat_card label="Users" count={@user_count}>detailsβ¦</.stat_card>
LiveComponents (use Phoenix.LiveComponent) add per-component state and their own handle_event β for self-contained interactive widgets within a page. Rule of thumb: function components by default; LiveComponent only when a widget needs its own event handling; a separate LiveView when it needs its own process/failure isolation.
Here's where the whole series converges. Because every browser tab is a process, and processes receive messages (handle_info β Book 2), broadcasting an update to every connected user is trivial:
# In mount (connected only!):
if connected?(socket), do: Phoenix.PubSub.subscribe(MyApp.PubSub, "orders")
# Anywhere in the app (context, background job, another user's LiveView):
Phoenix.PubSub.broadcast(MyApp.PubSub, "orders", {:new_order, order})
# Every subscribed LiveView process gets it as a message:
def handle_info({:new_order, order}, socket) do
{:noreply, stream_insert(socket, :orders, order, at: 0)}
end
stream_insert belongs to streams β LiveView's tool for large/append-only collections: the server sends only the new/changed item and forgets it (no memory growth per client); the client keeps the DOM. Chat feeds, tables, notification lists β always streams, not list assigns.
LiveView doesn't ban JS; it makes it a last resort with clean escape hatches. JS commands (Phoenix.LiveView.JS) run common client-side transitions (show/hide/toggle) without round-trips. Hooks (phx-hook="Chart") hand a DOM node to your JS (chart libraries, maps, editors) with lifecycle callbacks and bidirectional events. The pattern: Elixir owns state, JS owns pixels-only concerns.
LiveView = GenServer per tab + compile-time-diffed templates + WebSocket transport. mount (twice!), handle_event for user input, handle_info for the rest of the system, assigns as the single source of truth, streams for collections, PubSub for multi-user sync. You now know the entire modern Elixir stack β time to build something whole.