Elixir: Phoenix LiveView

LiveView renders HTML on the server and keeps a persistent WebSocket per session. When state changes, the server sends a minimal diff — no client framework, no API layer, and the UI stays in sync across every connected browser. One LiveView process per session is just a GenServer with rendering attached.

The LiveView Lifecycle

LiveView cycle: mount assigns state, handle_event or handle_info updates assigns, the changed assigns diff over the socket
Fig. 1 — Only the assigns that changed are diffed and shipped: LiveView tracks what you touched.
defmodule MyAppWeb.CounterLive do
  # The macro wires routing, rendering, and the process behaviour.
  use MyAppWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    # Subscribe this LiveView to cluster-wide updates.
    if connected?(socket), do: Phoenix.PubSub.subscribe(MyApp.PubSub, "counter")
    {:ok, assign(socket, count: 0)}
  end

  @impl true
  # Events from phx- bindings in the template land here.
  def handle_event("increment", _params, socket) do
    {:noreply, update(socket, :count, &(&1 + 1))}
  end

  @impl true
  # Broadcasts from OTHER sessions arrive as info messages.
  def handle_info({:tick, count}, socket) do
    {:noreply, assign(socket, count: count)}
  end
end

Templates and Components

<!-- counter_live.heex — HEEx templates are validated at compile time:
     typo'd attribute names and wrong HTML nesting fail the build. -->
<div id="counter">
  <p>Count: <%= @count %></p>

  <!-- phx- bindings turn DOM events into LiveView messages -->
  <button phx-click="increment">+1</button>

  <!-- function components: any assign-attributed function module -->
  <MyAppWeb.Components.badge value={@count} />
</div>
# A function component — a function that takes assigns and returns a template.
defmodule MyAppWeb.Components do
  use Phoenix.Component

  def badge(assigns) do
    ~H"""
    <span class={if @value > 9, do: "badge hot", else: "badge"}><%= @value %></span>
    """
  end
end

Streams: Large Lists

Assigning a 5,000-row list means LiveView re-diffs the whole collection. stream sends only the inserted/deleted rows — constant memory per collection, indexed DOM patches.

@impl true
def handle_info({:new_bid, bid}, socket) do
  # stream_insert patches ONE row in the DOM; no full-list diff.
  {:noreply, stream_insert(socket, :bids, bid)}
end

# In the template:
# <div id="bids" phx-update="stream">
#   <div :for={{id, bid} <- @streams.bids} id={id}>{bid.price}</div>
# </div>

State-Ownership Patterns

  • LiveView process per session — the default; ephemeral UI state lives in assigns.
  • Shared state in a named GenServer — the LiveView subscribes to it (PubSub) instead of owning it, so 100 sessions share one price feed.
  • Presence — tracks who is connected per topic, cluster-wide, using the same PubSub machinery.

The LiveView module is the client API of the pattern you already know: the process holds state, PubSub spreads events, and the browser gets diffs. Everything else on the page is ordinary Elixir.

Practice

  1. Build the counter above, open two browser tabs, and broadcast increments across them.
  2. Convert a large list render to stream/3 and watch the message size shrink in the browser inspector.
  3. Add a form with phx-submit and validate through an embedded-schema changeset.

Next: Designing Elixir Systems