skip to content

Immutable Updates & Equality

The comparison made at each seam and the discipline it forces: fresh snapshots, writes through a tracked handle, structural sharing, shallow versus deep equality. Two bugs follow when it is wrong.

on this pageshow

questions

6

When a runtime compares each new state value with the previous one by reference, why is an in-place mutation invisible?

level: juniorimportance: must knowfreq 80%

answer

  1. same object, not same contents
  2. contents changed, identity did not
  3. one pointer check, whatever the size
  4. fresh snapshot per change
  5. or write through the tracked handle

basics

~20 s

Reference comparison asks only whether the new value is the same object as the old one. Writing into a field changes contents but not identity, so the comparison answers "same" and dependent work is skipped. A fresh copy makes it visible.

solid answer

~40 s

At a seam that compares by reference, the only question asked is whether the new value and the old value are the **same object**. Writing into an existing object changes what it contains but not which object it is, so the comparison answers "same" and every piece of dependent work is skipped: the screen keeps showing the old field values although memory already holds the new ones. The discipline that follows is a fresh snapshot per change — build a new object or array that carries the updated field and hand *that* back. Runtimes that instead intercept property writes on a tracked handle can notice a mutation, because they observe the write itself rather than comparing two values; even there, only writes that travel through the handle are seen.

go deeper

for a junior

Recall the two halves of an object: which object it is, and what it holds. A write into a field changes only the second, so a reference comparison reports no change. Produce a new object instead.

for a middle

Explain the mechanics: the seam does one constant-time identity check, which is why copying at write time is the cheap side of the bargain, and why the previous value survives as a usable snapshot.

for a senior

Show that you recognise the fingerprint in production — a field that appears one interaction late, or only after an unrelated click — and that you fix it at the write site rather than by forcing extra passes.

for a principal

Frame it as a contract between a state layer and every consumer of it: which model the codebase compares under, and therefore which discipline is enforceable in review and by tooling rather than remembered.

## The seams where a comparison happens A component runtime never watches your data change. It learns about a change at a **seam** — a point where it compares what it has now against what it had before and decides whether dependent work must run. Typical seams: - the value a state holder hands back after a write, against the value it held; - one entry of a dependency list, against the same position in the previous pass's list; - the set of props handed to a child that is allowed to skip work when its inputs look unchanged; - a value read out of a shared container and handed to a subscriber. Most of those comparisons are **by reference**: the runtime asks whether the new value and the old value are the same object in memory, not whether they describe the same thing. Reference comparison is chosen because it is constant time — one check, whatever the size of the data behind it. ## Why an in-place write is invisible An object has two separable properties: **which object it is** (its identity) and **what it currently contains**. Writing into a field changes the second and leaves the first untouched. After an in-place mutation the seam is holding two names for one object, so the check answers "same" and the runtime concludes that nothing needs to happen. The data really did change; the comparison was simply not built to look inside. The observable result is that the new value sits in memory while the screen keeps painting the old one. It usually looks like the update was *lost*, which sends people hunting in the handler or in the network response, when the defect is one line earlier — at the moment the change was applied to an object the runtime already had. ## The rule a change must obey Under reference comparison the rule is short: **a change is a new value.** Instead of writing into the object the runtime holds: 1. copy the object you are changing; 2. set the changed field on the copy; 3. hand the copy back to the state holder (for nested data, repeat up the path to the root). Two properties follow. The comparison now sees a different object, so dependent work runs. And the previous value is still intact and unshared, which is what makes it safe to keep for a comparison, an animation between two states, or a history entry. ## Where a mutation is visible instead Not every runtime compares snapshots, so "mutation is invisible" is not a law of component frameworks — it is what follows from *reference comparison*. Some runtimes hand you a **tracked handle** that intercepts reads and writes on your state; a write through that handle is observed at the moment it happens, so no before/after comparison is needed and mutating syntax is legitimate. Others rely on a compiler that rewrites assignments to reactive declarations into notifications, so the assignment itself is the signal. | how the change is detected | what makes a change visible | what stays silent | |---|---|---| | reference comparison of snapshots | a freshly built value handed back | any write into the object already held | | interception on a tracked handle | a write that travels through the handle | a write to a plain value copied out of it | | compile-time instrumentation | an assignment the compiler analysed | a write the compiler never saw, such as one through an alias it does not follow | What *is* common to all three is that each model has one channel through which a change must travel to be noticed, and a change that bypasses the channel is silent. ## The bargain, and the mirror-image bug Copying costs an allocation and a shallow copy per changed object at write time, in exchange for comparisons that cost one check at every seam, on every pass. That trade is deliberate: writes are far rarer than comparisons. Nested data does not make it expensive, because only the objects on the path from the root to the change are replaced and every untouched branch is carried over by reference. The same reference comparison produces a second, opposite bug. Hand back a **freshly built object whose contents are identical** and the seam reports a change nobody needed: dependent work re-runs and produces exactly the output it produced before. So the two failure modes are a change nobody saw (mutation, identity unchanged) and work nobody needed (new identity, contents unchanged), and both are read off the same comparison.

  • If a mutation is invisible, why does the new value sometimes appear on screen a moment later?
    Because something unrelated triggers the dependent work again. A runtime that re-reads the state object at that point reads the mutated one and paints the new field. That is why this bug so often presents as an update that arrives "one interaction late" rather than as an update that never arrives.
  • Does handing back a fresh copy whose contents are identical cause dependent work to run?
    Yes. A reference comparison sees a different object and reports a change, so the work re-runs and produces the same output. That is the mirror-image bug: needless work rather than a missed update. The cure is to stop building a new value when nothing changed, not to deepen the comparison.
  • Is the old value safe to keep once a fresh copy has been handed back?
    Yes, provided nobody mutated it. Because the change produced a new object, the previous one is a complete, frozen-in-time snapshot; holding it is how a runtime compares passes, and how history or undo becomes cheap. Mutating shared state destroys that property along with the change detection.

A reference comparison is a courier who only scans the parcel's barcode. Repack the same box with different contents and the scan still says "already handled" — you have to send a new box.

saying these in an interview costs you the question

  • Thinks a reference comparison looks inside the object's fields.
  • Says the update was lost, when only the comparison missed it.
  • Believes calling the setter triggers work whatever value it carries.
  • Treats copying as a performance trick rather than the visibility rule.
  • Assumes mutation is forbidden in every framework, rather than under snapshot comparison.
open as a page

How do you update one field deep inside nested state without mutating it, and why is that cheap?

level: middleimportance: must knowfreq 72%

basics

~20 s

Replace only the objects on the path from the root to the changed field, and reuse untouched branches by reference. That structural sharing copies a few objects, not the tree, and leaves unchanged branches identity-equal so dependents still skip work.

open as a page

In a framework that tracks reads and writes through a state handle, why do destructured copies stop updating?

level: middleimportance: should knowfreq 62%

basics

~20 s

Tracking lives on the handle, not on the value. A value copied out is a plain snapshot with no link to the handle, so later writes never reach it and nothing re-runs. Read through the handle at each use.

open as a page

Why does a fresh object built during every render read as changed to every identity check downstream?

level: middleimportance: should knowfreq 70%

basics

~20 s

Each pass allocates a different object, and an identity check compares which object it is, not what it holds. Identical contents in a new wrapper report a change, so dependency lists re-fire and children that could skip work do not.

open as a page

A state change never appears on screen while other work re-runs producing identical output — how do you diagnose each?

level: seniorimportance: should knowfreq 58%

basics

~20 s

Capture the value at the seam across two passes and compare identity and contents as separate facts. Same identity with different contents means an in-place write; new identity with identical contents means a producer allocating each pass. Repair the producer.

open as a page

When is a shallow comparison enough at a seam, and what does a deep comparison at a frequently checked seam cost?

level: seniorimportance: should knowfreq 56%

basics

~20 s

Shallow comparison checks each top-level property by reference, which suffices under immutable updates, because a nested change forces new references up the path. A deep comparison walks the graph on every check, so its cost scales with the data.

open as a page