skip to content

When a component reads an external store through useSyncExternalStore, how does React detect that the store changed partway through a render, and what does it do so the committed tree stays consistent?

level: seniorimportance: should knowfreq 31%

answer

  1. React performs the read itself
  2. recorded value versus fresh read
  3. Object.is, not deep comparison
  4. changed means discard and redo
  5. stable snapshot or infinite loop

basics

~20 s

React remembers the snapshot each component rendered with, then re-reads it before committing and compares with Object.is. If any value moved, React throws that render away and re-renders synchronously — which only works if the snapshot value is stable between calls.

solid answer

~50 s

React makes the read visible to itself. During render it calls the store's snapshot function and remembers the value that component rendered with. Before the tree is committed, React re-reads the snapshot for every component that consulted the store and compares old with new using `Object.is`. If anything moved, React knows the render was assembled across a change, so it discards it and renders again with the current value rather than committing a mixed tree. It also stops treating that update as deferrable work — consistency is bought by giving up interruptibility for it, rendering it as blocking work instead. The whole mechanism rests on the snapshot being a cached, referentially stable value: return a freshly built object or array each call and `Object.is` reports a change every time, so React re-renders in a loop and warns in development that the snapshot should be cached.

code

javascript · 16 lines
javascript
const state = { items: [{ done: false }, { done: true }], version: 1 };

// Wrong: a new array on every call, so identity never matches.
const getVisibleUnstable = () => state.items.filter((i) => !i.done);

// Right: recompute only when the store's version advances.
let cache = { version: -1, value: [] };
function getVisible() {
  if (cache.version !== state.version) {
    cache = { version: state.version, value: state.items.filter((i) => !i.done) };
  }
  return cache.value;
}

console.log(getVisibleUnstable() === getVisibleUnstable()); // false
console.log(getVisible() === getVisible()); // true

go deeper

for a junior

Know the shape of the guarantee: React reads the external value on your behalf and re-checks it before showing the tree, instead of trusting a value it never saw being read.

for a middle

Explain the check itself — the value recorded at render time versus a fresh read at commit time, compared by identity — and why a changed value means the render is discarded rather than patched.

for a senior

Add the cost and the constraint: these updates render as blocking work and give up interruptibility, and an unstable snapshot turns the identity check into an infinite re-render loop.

for a principal

Reason about the aggregate: how many components read a hot store, how coarse their snapshots are, and when the consistency guarantee is paying for itself versus quietly cancelling out the responsiveness you adopted concurrent rendering for.

## The core idea: make the read visible React cannot verify a value it does not know was read. A component that reaches into a module object during render just looks like a function that returned elements. The tearing-safe path exists to close that gap: the component asks React for the value, React performs the read on its behalf, and now React has something it can check. Everything else is bookkeeping around that one shift. ## Step one: record what was rendered with During the render pass, React calls the store's snapshot function and stores the returned value on the fiber alongside the rest of that component's hook state. That recorded value is the claim the render is making: *this component's output was computed from exactly this value.* ## Step two: verify before commit A render is not a commit. Between finishing the tree and putting it on screen, React re-reads the snapshot for every fiber that consulted a store, and compares the fresh read against the recorded one with `Object.is` — the same identity comparison React uses for bailing out of re-renders elsewhere. Two outcomes: - **Equal.** Nothing moved during the render. The tree is coherent and gets committed. - **Not equal.** The store changed somewhere between the read and now, so the tree may contain values from two different moments. React does not attempt to patch the affected components — it discards the work and renders again with the current value. Redoing a render is cheap in the sense that matters: a render pass produces no DOM changes until commit, so throwing one away has no user-visible cost beyond the time spent. ## Step three: stop yielding for this update Re-checking is not enough on its own, because a re-render that is itself spread across several tasks could tear again, and a store that changes rapidly could keep invalidating attempts. So React also renders the store-driven update as blocking work rather than deferrable work: it does not spread that re-render across tasks in a way that reopens the window. This is the real tradeoff of the mechanism, and interviewers like to hear it named. External-store reads buy consistency by surrendering some of the interruptibility that concurrent rendering exists to provide. A store that changes constantly and is read by a large subtree can therefore claw back the responsiveness you adopted transitions to gain. ## Why snapshot identity must be stable The comparison is identity-based, so the snapshot function must return the *same value* whenever the store has not changed. Derive a new object or array on every call and the check always reports a change — React re-renders, re-reads, sees another new object, re-renders again. In development React surfaces this by warning that the result of the snapshot function should be cached to avoid an infinite loop. The two ways out are the same ones any cache uses: ```js // Wrong: a new array every call, so identity never matches. const getVisible = () => state.items.filter((i) => !i.done); // Right: recompute only when the store's version actually advances. let cache = { version: -1, value: [] }; function getVisible() { if (cache.version !== state.version) { cache = { version: state.version, value: state.items.filter((i) => !i.done) }; } return cache.value; } ``` Either the store hands out an immutable state object that it replaces wholesale on change (so identity naturally tracks change), or the derivation is memoised against a version marker. Returning a primitive — a number, a string, a boolean — is trivially stable and is the simplest correct answer when a component only needs one field. ## Why the check has to happen at commit time, not later A post-commit correction would still put the inconsistent frame on screen for at least one paint. The whole value of the mechanism is that the contradictory tree is never shown, which is only achievable while React still owns the work and has not handed it to the DOM. ## What to say in an interview Three beats: React records the value each component rendered with; it re-reads and compares by identity before committing and redoes the render if it moved; and it renders that update as blocking work so the redo cannot tear either. Then add the constraint that pays for it — the snapshot must be referentially stable, or the identity check misfires into an endless re-render loop.

  • Why does React compare snapshots with Object.is rather than a deep equality check?
    Because the comparison runs for every store-reading component on every consistency check, and deep comparison would be unbounded work proportional to the data. Identity is constant-time and puts the caching responsibility where the information lives — in the store, which knows when it actually changed.
  • What does the tearing-safe read cost, compared with reading the store directly during render?
    Interruptibility. Store-driven updates are rendered as blocking work rather than being deferred, so a very hot store read by a large subtree can undo the responsiveness gains that concurrent features are supposed to deliver. That argues for narrow snapshots and for keeping the number of components reading the store small.
  • If the snapshot returns a derived array, what symptom appears first?
    A development warning that the snapshot result should be cached, along with a component that re-renders continuously and pins the CPU. The identity check sees a brand-new array on every read, concludes the store changed, re-renders, and repeats forever. Cache the derivation against a version marker or return a primitive.
  • Why must the verification happen before commit rather than being corrected afterwards?
    Because anything corrected after commit has already been painted — the user saw a self-contradictory frame, which is the exact defect the mechanism exists to prevent. While the work is still uncommitted React owns it entirely and can discard it at no visible cost.

saying these in an interview costs you the question

  • React deep-compares the snapshot to detect changes
  • The store notifies React during the render, so no check is needed
  • Returning a fresh derived array each call is fine, React memoises it
  • React patches the stale components after commit instead of re-rendering
  • Making a store read tearing-safe has no performance cost

context