# How LiveView decides what to re-render

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.

```elixir
# 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`):

```elixir
defstruct [:static, :dynamic, :fingerprint, :root, caller: :not_available]
```

Take a two-assign component:

```elixir
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:

```elixir
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 `dev` you'll see extra `<!-- <Demo.greeting> -->` comments inside the statics. Those are `debug_heex_annotations` (config'd in `dev.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 `true` as 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 the `dynamic` list will be `nil`.

So the same template, re-rendered when only `:name` changed:

```elixir
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`):

```elixir
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`):

```elixir
# @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:

```elixir
# ✅ 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:

```elixir
# ✅ 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:

```elixir
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.

```elixir
# 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:

1. **`~H` → `%Rendered{static, dynamic, fingerprint}`.** Statics sent once; dynamics are a function of the assigns.
2. **`__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 to `nil` and drops out of the diff.
3. **`assign/3` is the sole writer of `__changed__`.** Mutate around it and the gate never opens (stale render); touch the bare `assigns` map 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:**

- [`Phoenix.Component.assign/3`](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#assign/3)
- [`Phoenix.LiveView.Engine` — change tracking & `%Rendered{}`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Engine.html)
- [Assigns and HEEx templates — LiveView docs](https://hexdocs.pm/phoenix_live_view/assigns-eex.html)
- [`Phoenix.Component.update/3`](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#update/3)


---

Created by: almirsarajcic
Date: July 09, 2026
URL: https://elixirdrops.net/d/7hB6qX9z
