skip to content

A child mutates a field of an object it received as an input. What breaks, and why do declared types miss it?

level: seniorimportance: must knowfreq 55%

answer

  1. inputs are shared, never copied
  2. the binding is read-only, the graph is not
  3. the reference did not change
  4. shape is not a write permission
  5. checks run at entry, not on every write

basics

~20 s

The child and the owner hold the same object, so a field write changes the owner's data with no notification, and readers disagree afterwards. Declared types describe shape, not writability, and a check runs when the value arrives, not on every later write.

solid answer

~50 s

An object input is shared, not copied: the child holds the very object the owner holds. Writing to a field changes the owner's data through a channel the framework knows nothing about, and the fallout depends on the reactivity model. A runtime that compares references before doing work sees the same reference and schedules nothing, so parts of the screen that read the field after the write show the new value while everything else stays as last derived. A fine-grained runtime notices only if that object is tracked; then the write "works", and the value has two writers with no defined order. Memoised derivations keyed by reference are wrong either way: the key did not change while the input did. Declared types miss this because a shape says which fields exist, not who may write them, and a validator checks the value at entry and never looks again.

go deeper

for a junior

Remember that an object input is the same object the caller holds. Do not write into it. Build a new value and report upward so the owner can accept the change.

for a middle

Explain the mechanism: a shared reference, a write with no notification, and a reference comparison that sees nothing new. Say why a shape declaration and an entry-time validator both miss it.

for a senior

Diagnose it in production: a screen stale in a pattern no data flow explains, memoised derivations returning old results, reproduction that depends on render order. Then remove the write rather than forcing a refresh.

for a principal

Decide where this is prevented rather than found: deeply read-only declarations, freezing data at the boundary where it enters the tree, and one review rule short enough to apply on sight — and price each against ergonomics.

## What actually happened Inputs are passed by reference. When the owner renders a child with an object, list or function, no copy is made on the way down; the child's slot points at the owner's value. So a write to a field of that value is a write into the owner's data, performed from below, with no notification, no ordering guarantee and no record of who did it. The binding was read-only and nobody broke that rule. The *graph reachable through* the binding was not, and that is the half people forget. ## What breaks, by reactivity model | runtime behaviour | what the mutation looks like | |---|---| | compares references before re-rendering | the reference is unchanged, so no work is scheduled; the data is new and the screen is stale | | fine-grained tracking over a tracked object | the write is observed and the screen updates, so the bug hides and the value now has two writers | | fine-grained tracking over a plain object | the write lands and nothing is notified, exactly like the reference-comparing case | | periodic value comparison | the next check sees a differing field and refreshes, which masks the problem until the app moves to a reference-comparing path | The worst outcome is not the broken screen; it is the case where it *works*. A mutation that happens to be observed becomes load-bearing, and the value's real owner is now whichever component ran last. ## The second-order damage - **Memoised derivations go wrong.** A derived value cached against the input's identity keeps its stale result, because the identity did not change while the contents did. - **Caches and comparisons keyed by reference lie.** Anything that asked "is this the same value as last time?" gets "yes" for two different values. - **Reproduction becomes ordering-dependent.** Whether the screen is right depends on which subtree rendered first, so the bug appears and disappears with unrelated changes. - **History, undo and audit trails break.** A record of "the state at this moment" that holds a reference now describes the present, not the past. - **Review cannot see it.** The write looks local. Nothing in the child's own file says the object came from above. ## Why the checks you have do not catch it 1. **A declared shape is not a permission.** A type declaration says which fields exist and what they hold. Unless the shape is *deeply* read-only, it says nothing about who may write them — and a shallow read-only declaration only stops reassignment of the slot, not writes into the object it points at. 2. **Static checks reach only compiled callers.** A value that arrives decoded from a payload, widened to an opaque shape, or through dynamically assembled markup was never constrained by the declaration. 3. **A runtime validator checks the entry, not the lifetime.** It inspects the value as the input arrives and has no visibility into later field writes. 4. **Tests often share the fixture.** A test that builds one object, renders with it and asserts on that same object can be satisfied by the mutation it should have caught. ## The fix, and how to make it stick The rule to state out loud is: *do not reassign the input, and do not write into what it points at.* When the child needs a different value, it builds a replacement and reports upward; the owner accepts it and re-supplies it downward, so exactly one writer exists and the change flows the normal way. Mechanically, three defences work together: 1. **Deeply read-only shapes at the declaration.** This turns the mistake into a build failure for every caller that compiles against it — the cheapest place to catch it. 2. **Freezing shared data at the boundary.** Where data enters the tree from a payload or a store, freezing it makes an offending write fail loudly at the moment it happens rather than surfacing as a stale screen three components away. 3. **A review rule with a name.** "A component never writes into anything it did not create" is short enough to apply on sight and catches the cases the first two miss. When you are the one debugging it, the tell is a screen that is stale in a pattern no data flow explains — one region showing a field's new value, another showing the old one, with no re-render in between. Look for a write inside a component to something it received rather than something it made. ## The line against ownership Noticing that the child wanted to change the value at all is a signal worth acting on. If that keeps happening, the value may be living in the wrong place — but that is a question about where state should be owned, and the answer is not "write to it from below". The rule here is narrower and absolute: whatever came down is not yours to write.

  • Why can mutating an object input make part of the screen update while the rest stays stale?
    The field really did change, so anything that reads it after the write shows the new value. But a runtime that compares references before scheduling work sees an unchanged reference and refreshes nothing, so regions that had already derived their output keep it. The result is a split screen no data flow explains.
  • If the mutation happens to be observed and the screen updates correctly, is it still a defect?
    Yes, and a worse one. The value now has two writers with no defined order, the behaviour depends on a tracking detail rather than on the contract, and any memoisation or reference comparison added later silently breaks. It also hides the real question of where the value should live.
  • What makes a deeply read-only declaration a better defence than a code-review rule alone?
    It moves the failure to build time for every caller compiled against it, at no runtime cost, and it documents the intent at the declaration where readers look. Review still matters for values that arrive from outside the build, but the declaration catches the ordinary cases without anyone having to notice.

It is like editing the original document your manager lent you instead of sending back a revision: the words change, nobody is told, and two people now believe they are holding the current version.

saying these in an interview costs you the question

  • Says mutating an object input is fine because the reference is unchanged
  • Thinks a declared shape prevents writes into the object
  • Believes the framework copies object inputs on the way down
  • Treats a mutation that happens to update the screen as correct
  • Assumes a runtime validator keeps watching the value after it arrives
  • Fixes the stale screen by forcing a re-render instead of removing the write