skip to content

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

level: middleimportance: should knowfreq 70%

answer

  1. new allocation, same contents
  2. identity churn, not data change
  3. only hurts where a seam compares
  4. hoist, split into primitives, or cache
  5. effect plus write equals a loop

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.

solid answer

~50 s

An object literal, array literal or function created inside a render is a **new object on every pass**. Downstream seams compare by reference, so they see a different value each time and report a change even though every field holds what it held before — a child allowed to skip work when its inputs are unchanged never skips, a dependency entry looks new on every pass, and a cache keyed on that input never hits. Nothing is *wrong* in the output; the work is simply needless, and in the effect case it can loop, because the work writes state, the write starts a pass, and the pass rebuilds the object. The fix is to stop producing a new value: hoist it out of the render path, hand over primitives instead of a wrapper, keep it in state, or build it only when its inputs change.

go deeper

for a junior

Remember that an object or function written inline is created again on every pass, so anything comparing by reference sees something new even when the fields are identical.

for a middle

Explain which seams actually care — a child that may skip, a dependency list, a cache key — and name the fixes: hoist, pass primitives, keep it where it survives passes, or build it only when inputs change.

for a senior

Show judgment about when not to act: identity churn with no seam behind it costs one allocation. And be able to trace the loop where an effect's write rebuilds its own dependency.

for a principal

Treat input stability as part of a shared module's contract: consumers will memoise and will list your values as dependencies, so which values a module promises to keep stable is an API decision, not an implementation detail.

## What "unstable identity" means Every time a component's body runs, the literals in it are evaluated again. `{ page: 1, size: 20 }` written inline is not one object that happens to be reused — it is a fresh allocation per pass, holding the same field values as its predecessor. The same is true of an array literal, and of a function written inline: a function is an object too, and a new one each pass. A seam that compares by reference answers only "same object or not". Identical contents in a different wrapper therefore read as **changed**. The value is honest about nothing having changed; the comparison cannot see that. ## Where it actually hurts It only hurts where a seam compares: - **A child that may skip work when its inputs are unchanged** receives a new object every pass and so never skips. The skip was the whole point of putting the comparison there. - **A dependency list** compares entry by entry against the previous pass's list. One freshly built entry makes the list look different on every pass, and the dependent work re-runs every time. - **A cached computation** keyed on that value recomputes on every pass, so the cache costs bookkeeping and returns nothing. - **An effect that also writes state** can loop: the write starts a new pass, the pass rebuilds the dependency object, the object looks new, the effect runs again. This is the failure mode that shows up as a spinning page or a repeating request rather than as a harmless waste. Where nothing downstream compares by identity, an inline object costs one allocation and no more. Chasing stability everywhere is its own waste — the question to ask is always "is there a seam here?". ## Why comparing harder is the wrong first move The tempting fix is to make the seam compare contents instead of identity. It works, and it moves the cost: a content comparison runs on every check and grows with the value, while the identity check it replaced was a single test. It also does not remove the new object — anything else keyed on identity still sees churn. The cheaper move is upstream: stop handing over a new value when nothing changed. | symptom | cause | the honest fix | |---|---|---| | child never skips its work | inline object or inline function passed as an input | hoist, cache against real inputs, or pass primitives | | dependent work re-runs every pass | a freshly built entry in the dependency list | depend on the primitive fields, not on the wrapper | | repeating work that writes state | the write triggers the pass that rebuilds the dependency | break the cycle at the producer, not by removing the dependency | | cache never hits | its input is rebuilt each pass | stabilise the input first, then the cache is meaningful | ## Four ways to stabilise a value 1. **Hoist it.** A value that does not depend on state or props is a constant; define it once outside the render path and every pass hands over the same object. 2. **Pass primitives.** Numbers, strings and booleans compare by value, so two separate passes carrying `page = 1` and `size = 20` compare equal with no wrapper to keep stable. Splitting a small config object into two inputs is often the whole fix. 3. **Keep it in state, or in a stable container.** A value stored where it survives passes is the same object next pass by construction. This is the right answer for values that must persist, such as a controller or a collection built once. 4. **Build it only when its inputs change.** Cache the construction against the values it derives from — and check that those inputs are themselves stable, or the cache has merely moved the problem one level up. A note on the second option: stabilising a value whose contents genuinely change every pass is not stabilisation, it is a bug. Holding on to the old object while the data behind it moves produces a screen that shows stale values — the invisible-change failure, reached from the other direction. ## What differs by reactivity model The problem belongs to models where identity is the signal. In a runtime that re-runs the component and compares, unstable inline values are a constant background concern and the discipline is learned early. In a runtime with fine-grained read tracking, most of these seams do not exist: dependencies come from reads at the point of use rather than from a list of values compared between passes, so an inline object matters only where you deliberately hand it to something that compares. In a compile-time model, the compiler often knows which values can change and can keep the others stable for you. The concept to carry between all of them is the same: a seam that compares by identity is only as useful as the stability of the values you feed it.

  • Why is passing two primitives often better than stabilising one options object?
    Primitives compare by value, so nothing has to be kept stable: two passes carrying the same number compare equal by construction. It removes the seam's dependence on a producer's discipline, and it removes the cache or hoist you would otherwise have to maintain and get right.
  • Is an inline object always a problem?
    No. It costs one allocation, and that is all, unless something downstream compares it by identity — a child that may skip work, a dependency list, a cache key. Stabilising values that nothing compares adds code and bookkeeping for no gain, so look for the seam before reaching for a fix.
  • How do you tell identity churn from a genuine change while debugging?
    Hold the previous value at the seam and compare the two facts separately: whether the identity differs, and whether the contents differ. New identity with identical contents is churn from the producer; identical identity with different contents is a mutation. Both look like "the comparison is wrong" until you split them.

saying these in an interview costs you the question

  • Thinks two object literals with identical fields compare as equal.
  • Reaches for a content comparison at the seam before checking the producer.
  • Stabilises a value whose data genuinely changes, and ships stale output.
  • Removes a dependency entry to stop a loop instead of stabilising it.
  • Assumes an inline function is cheap because it holds no data.