Book 5 β€” LiveView: Real-Time UI Without JS

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.

πŸ““ Companion notebook: 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.

1. The big idea: a GenServer per browser tab

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)LiveViewReact (for orientation)
init/1mount/3constructor + initial state
statesocket.assignsuseState values
handle_call/casthandle_event/3 (user events)event handlers
handle_info/2handle_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.

Feel it: simulated counter with wire traffic

simulated CounterLive β€” watch what crosses the wire
0
// click a button…

2. The lifecycle: two mounts, one process

A LiveView page loads in two phases β€” this trips everyone up once, so let's make it interactive:

πŸ”„ Lifecycle explorer

HTTP GET
(dead render)
mount #1
(static HTML out)
WebSocket
connects
mount #2
(live process!)
events ⇄ diffs
(the long life)
Press the buttons in order to walk the lifecycle.

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.

QUIZ 5.1

You start a 1-second :tick timer in mount/3 unconditionally. What's the bug?
The dead-render process exits after producing HTML, so its timer is wasted work (and anything with side effects β€” PubSub subscriptions, presence tracking β€” would be wrong). Idiom: if connected?(socket), do: :timer.send_interval(1000, :tick).

3. Events: phx- bindings

The client side is declarative attributes; the server side is handle_event/3 clauses (pattern matching again):

BindingFiresHandled 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 submitthe actual write
phx-keydown, phx-blur, phx-focus…what you'd expectmore 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.

4. Assigns & diff tracking β€” why it's fast

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).

The rules that make it work: (1) Change state only via 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.

5. Components β€” composition without a component framework

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.

6. Real-time fan-out: PubSub + streams

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.

QUIZ 5.2

500 users watch a dashboard. One admin updates a metric. What does "push the update to all 500" cost the server?
PubSub delivers to all subscribed processes (cheap BEAM messages). Each LiveView re-renders only changed slots and ships bytes-sized diffs. This is why a single modest server handles tens of thousands of live dashboards.
Deep dive: when you DO need JavaScript

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.

Recap

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.