We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
How LiveView decides what to re-render
almirsarajcic
Your LiveView does not re-render its template when an assign changes. It re-runs only the fragments that read an assign that actually changed, and computes every other fragment to nil so it never touches the wire. That’s the whole reason a LiveView can push a 3-byte diff instead of a full page. But the machine that decides “changed or not” is easy to defeat by accident — and when you defeat it, nothing warns you. Here’s exactly how it works, straight from the phoenix_live_view source.
# A HEEx template is NOT a string. It compiles to a %Rendered{} struct:
# a list of STATIC segments + a function that produces the DYNAMIC values.
rendered.static
#=> ["<p>Hello, ", "!</p>\n<p>The year is ", ".</p>"]
# Re-render when ONLY :name changed (year is untouched):
changed.dynamic.(true)
#=> ["Grace", nil]
# ^^^ :year is unchanged -> computed to nil -> never sent
That nil is the entire game. The statics are sent once and cached in the browser; on every update LiveView runs the dynamic function, and any dynamic whose assigns didn’t change comes back nil and is dropped from the diff. Miss the mechanism and you’ll either send far too much or — worse — silently send nothing when you meant to update.
The template is a %Rendered{} struct
When you write ~H"...", the compiler doesn’t produce HTML. It produces a Phoenix.LiveView.Rendered struct (engine.ex):
defstruct [:static, :dynamic, :fingerprint, :root, caller: :not_available]
Take a two-assign component:
defmodule Demo do
use Phoenix.Component
def greeting(assigns) do
~H"""
<p>Hello, {@name}!</p>
<p>The year is {@year}.</p>
"""
end
end
Rendering it gives you the split explicitly:
rendered = Demo.greeting(%{name: "Ada", year: 2026, __changed__: %{name: true, year: true}})
rendered.static
#=> ["<p>Hello, ", "!</p>\n<p>The year is ", ".</p>"]
rendered.dynamic.(true)
#=> ["Ada", 2026]
The static list is the literal HTML around your interpolations — invariant, sent to the browser exactly once. dynamic is a function that returns the current values of the {...} holes. fingerprint is a number identifying this template’s structure, so LiveView can tell “same template, different values” (send a diff) from “different template” (send everything). Statics and dynamics interleave: static[0] <> dynamic[0] <> static[1] <> dynamic[1] <> static[2].
Aside: in
devyou’ll see extra<!-- <Demo.greeting> -->comments inside the statics. Those aredebug_heex_annotations(config’d indev.exs), stripped in prod — not part of the real payload.
__changed__: the map that gates every dynamic
Change tracking rides on one special assign: __changed__. From the engine’s own docs:
The map should contain the name of any changed field as key and the boolean
trueas value. If a field is not listed in__changed__, then it is always considered unchanged. If a field is unchanged and live believes a dynamic expression no longer needs to be computed, its value in thedynamiclist will benil.
So the same template, re-rendered when only :name changed:
changed = Demo.greeting(%{name: "Grace", year: 2026, __changed__: %{name: true}})
changed.dynamic.(true)
#=> ["Grace", nil]
:year isn’t a key in __changed__, so its dynamic short-circuits to nil and is left out of the diff. The gate itself is three lines (engine.ex):
def changed_assign?(changed, name) do
case changed do
%{^name => _} -> true # listed -> changed, recompute
%{} -> false # absent -> unchanged, skip (nil)
nil -> true # no map -> tracking off, always recompute
end
end
That last clause matters: if __changed__ is nil (e.g. the dead render, or a component called by hand outside HEEx), tracking is off and everything recomputes. Tracking is an optimization layered on top of a correct always-render baseline.
@name is a dependency, not a variable
How does a dynamic know which assigns it depends on? The compiler rewrites @name into assigns.name and records the dependency (engine.ex):
# @name compiles to assigns.name PLUS a recorded dependency on :name
{:@, meta, [{name, _, context}]} ->
{..., Map.put(assigns, {:changed, [name]}, true)}
So {@name} is wired to recompute only when :name is in __changed__. This is why a function call over an assign is completely fine — the dependency is the assign you reference, not the function:
# ✅ tracked precisely — this dynamic depends on :price alone,
# so it recomputes only when :price actually changes
{format_price(@price)}
# ✅ still tracked — @user.name, assigns.price, assigns[:price]
# are all shapes the compiler can analyze
{@user.name}
{assigns[:price]}
# ❌ strong-tainted — the moment you touch the bare `assigns` map
# in a way the compiler can't read, this dynamic is recomputed
# on EVERY render, even when nothing it reads changed
{Map.get(assigns, :price)}
The engine calls that last case strong-tainting: “Strong-tainting only happens if the assigns variable is used.” When you hand the whole assigns map to a function (or define a variable / import inside the template — weak-tainting), the compiler can no longer prove which keys the fragment reads, so it gives up and marks the dependency as :all. The fragment now recomputes on every single render. It still produces correct HTML — it just quietly forfeits the optimization. Reach for @price, assigns.price, or assigns[:price]; never Map.get(assigns, :price) or assigns |> something.
assign/3 is the only thing that writes __changed__
Here’s the failure mode with no error message. __changed__ doesn’t populate itself — assign/3 populates it, by routing through force_assign (utils.ex), which does Map.put_new(changed, key, ...). Bypass assign/3 and mutate the socket directly, and __changed__ never learns about the change:
# ✅ assign/3 records the change — the dynamic will recompute
socket = assign(socket, :count, 1)
socket.assigns.__changed__
#=> %{count: true}
# ❌ direct mutation — __changed__ stays empty, the DOM goes STALE
socket = %{socket | assigns: Map.put(socket.assigns, :count, 1)}
socket.assigns.__changed__
#=> %{}
Feed both to the gate and the consequence is stark:
Phoenix.LiveView.Engine.changed_assign?(%{count: true}, :count)
#=> true # assign/3 -> fragment recomputes, browser updates
Phoenix.LiveView.Engine.changed_assign?(%{}, :count)
#=> false # mutation -> fragment skipped, screen never changes
The value in socket.assigns is updated — IO.inspect shows the new count, your tests on assigns pass — but the rendered page keeps showing the old one, because the diff that would carry it was never generated. This is the single most confusing “my LiveView won’t update” bug, and it’s why assign/3 (and update/3, assign_new/3) are not stylistic preferences: they’re the only functions that maintain the tracking contract.
One more property that falls out of the same code: assigning the same value is a deliberate no-op.
# current count is already 5
socket = assign(socket, :count, 5)
socket.assigns.__changed__
#=> %{} # equal value -> not marked changed -> nothing re-sent
assign/3 compares with == first (utils.ex); an equal value never enters __changed__, so re-assigning unchanged data in handle_info or handle_params costs you nothing on the wire.
The mental model
Three moving parts, one loop:
~H→%Rendered{static, dynamic, fingerprint}. Statics sent once; dynamics are a function of the assigns.__changed__gates the dynamics. A dynamic recomputes only if an assign it statically references (@x,assigns.x,assigns[:x]) is listed as changed; otherwise it resolves toniland drops out of the diff.assign/3is the sole writer of__changed__. Mutate around it and the gate never opens (stale render); touch the bareassignsmap and the gate is always open (wasted render).
Write your data-derivations as functions over @assigns, always update through assign/3, and never pass the whole assigns map to a helper — and LiveView’s diff engine does what it was built to do: send the three bytes that changed and nothing else.
Links:
copied to clipboard