F6.06 · part 3 of 3
render & diffs
A LiveView re-renders on every change, yet stays cheap, because a HEEx template is split in two: fixed static markup and interpolated dynamic holes. Only the dynamic values that actually changed travel the socket — the diff is the minimal delta of the rendered state since the last render.
First the split that makes change tracking possible, then a worked diff computed for real, then
render/1 in Elixir with its diffs annotated, and finally streams — for collections
too large to keep in socket assigns.
The static / dynamic split
The ~H sigil does not produce a string. It compiles the template into two interleaved
parts that render/1 returns as rendered structure: the static segments
and the dynamic holes between them. That separation is the whole mechanism — it is what
lets the framework track each interpolated value on its own and re-send only the ones that moved.
- static
- The fixed markup — tags, attributes, and the literal text around the holes. Sent on the first render, cached on the client, and never transmitted again.
- dynamic
- Each interpolated expression, one numbered hole per
{...}. The hole is tied to the assigns it reads, so its value is tracked across renders. - change tracking
- Comparing each hole’s current value with its previous one. An unchanged assign
is skipped even when
render/1ran again. - diff
- The map of changed holes, keyed by position — the minimal delta of the rendered state since the last render, and all that goes over the wire.
Reading the template, a hole is anything in curly braces: {@count} is hole
0, the title inside the list comprehension is hole 1. Everything
else — <h1>, <ul>, the spaces and angle
brackets — is static, fixed at compile time. The reader can split a template by hand the same way the
compiler does.
HEEx asks for assigns in the holes rather than strings built in code for exactly this reason: a value the compiler can see as a distinct hole can be tracked and diffed; a value hidden inside a concatenation cannot.
A diff, computed
Take a catalog view with two holes: {@count} at position 0
and {course.title} at position 1. The first render sends the
static shell once, plus both hole values. After that, every event re-runs render/1, and
the diff carries only the holes whose values differ from the previous render — addressed by position, not
by markup.
Choose what an event changes. The figure recomputes the real payload: which holes re-send, and the exact map the socket carries.
The diff over the socket · choose what changed
# @count 41 -> 42, @courses unchanged # diff over the socket: %{"0" => "42"}
holes re-sent: 1 of 2
The cost of a re-render is proportional to what changed, not to the size of the page — the exact inverse of a full request, which re-sends everything every time. When nothing changed, the diff is empty and the socket stays silent.
render/1 in Elixir
A LiveView’s render/1 takes assigns and returns HEEx from
the ~H sigil — the same template language compiled in F6.05, now the projection of
a process’s state. The data still arrives through the Portal facade in
mount/3; render/1 only reads @count and
@courses from the socket. The two annotated cases below are the figure’s two
holes, in code.
def render(assigns) do ~H""" <h1>Courses ({@count})</h1> <%!-- hole [0] --%> <ul> <li :for={course <- @courses}>{course.title}</li> <%!-- hole [1] --%> </ul> """ end # an event re-runs render/1; the diff is the changed holes only: # @count 41 -> 42, @courses same # => %{"0" => "42"} # @courses gains a row, @count same # => %{"1" => [...]} (shell never re-sent)
Nothing here manages the wire. render/1 is a plain function of assigns;
breaking a view into smaller function components from F6.05 sharpens tracking further, since a component whose
assigns did not change is skipped whole.
Streams for big lists
Change tracking keeps the diff small, but a large collection in assigns is still a cost of its own: the process
holds every row in memory and re-checks the list on each render. stream/3 removes that.
The rows live in the DOM, the server tracks only their ids, and stream_insert/3 appends
or updates one row without the LiveView retaining the list. The template marks the container
phx-update="stream" and iterates @streams.courses.
def mount(_params, _session, socket) do {:ok, stream(socket, :courses, Portal.list_courses())} end def handle_event("add", %{"course" => params}, socket) do {:ok, course} = Portal.create_course(params) {:noreply, stream_insert(socket, :courses, course)} # one row, not the whole list end <%!-- the rows live in the DOM, not in socket memory --%> <ul id="courses" phx-update="stream"> <li :for={{dom_id, course} <- @streams.courses} id={dom_id}>{course.title}</li> </ul>
F5 OTP
A GenServer answers a message by returning only its new state — never the whole history.
a diff is
that same minimal delta over a socket: render/1 projects state, and only the
changed holes are sent.
Streams are how a feed or the F6.09 live dashboard scales to many rows and many connected clients without the server holding each list. The reduction is the recurring shape of F5/F6: a long-lived process, a function from its state to a view, and a minimal delta on every change.
What this lands
render/1returns HEEx from the~Hsigil — a function of assigns, not a string.- A template compiles into static segments and dynamic holes; the static shell is sent once and cached.
- Change tracking compares each hole to its previous value, so only changed assigns produce a diff.
- The diff travels the socket, keyed by hole position — the minimal delta of the rendered state, not the whole document.
stream/3keeps large or append-only collections in the DOM, so the server holds ids, not the list.
That completes F6.06. A LiveView is an OTP process holding socket assigns, rendering HEEx, and shipping minimal diffs — interactive UI with the domain untouched below the facade. Next, the chapter adds F6.07 — PubSub, channels & real-time so a change broadcasts to every connected LiveView at once. Back to the F6.06 overview or the F6 chapter.
References
How render, change tracking, and streams are specified.
Sources
- Phoenix LiveView — Assigns & change tracking — the static / dynamic split and how diffs are computed.
- Phoenix.Component —
~H— the HEEx sigil thatrender/1returns. - Phoenix.LiveView —
stream/3— collections held in the DOM, not in assigns.