jonnifyelixir
F6 / F6.06 / render

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/1 ran 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

RENDERED STATE — one static shell, two dynamic holes static shell · sent once on first render, then never again HOLE [0] {@count} = 42 re-sent HOLE [1] {course.title} list unchanged PAYLOAD ON THE SOCKET %{"0" => "42"} only changed holes are sent; the shell and unchanged holes are skipped
# @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/1 returns HEEx from the ~H sigil — 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/3 keeps 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

Related in this course