skip to content

A custom hook subscribes to an external store inside useEffect and mirrors the current value into useState on every change. Under React 19 concurrent rendering, why can components using that hook still show inconsistent values in one commit?

level: seniorimportance: should knowfreq 30%

answer

  1. effects run after commit, not during
  2. one shared fact, N independent copies
  3. new mounts seed straight from the store
  4. subscription setState is an ordinary update
  5. nothing left for React to verify

basics

~20 s

Because an effect cannot run in the middle of a render. A component mounting during a render seeds its copy from the store as it is at that moment, while already-mounted components still hold whatever their subscription last delivered — so one commit contains two versions.

solid answer

~50 s

An effect only runs after a commit, so a subscription cannot participate in the render that is currently reading values. That leaves two ways to end up inconsistent. First, seeding: a component mounting mid-render initializes its state from the store as it is right then, while its already-mounted siblings still hold the copy their subscription last pushed — if the store moved during the render, those are different values in the same commit. Second, priority: the `setState` a subscription callback fires is an ordinary update with no special standing, so React is free to schedule it like any other, and different components' copies can land in different passes. The pattern also cannot detect that the store moved between the render and the commit, because nothing checked. Only a read React knows about can be verified before the tree goes on screen.

code

javascript · 12 lines
javascript
import { useEffect, useState } from 'react';

// The pattern that can still tear under concurrent rendering.
export function useStoreValue(store) {
  // A component mounting mid-render seeds from the store as it is right now...
  const [value, setValue] = useState(() => store.getState());

  // ...while this only starts delivering values after the commit.
  useEffect(() => store.subscribe(() => setValue(store.getState())), [store]);

  return value;
}

go deeper

for a junior

Recognise the shape of the pattern — subscribe in an effect, copy the value into state — and know that effects run after React has finished rendering and committed, never in the middle.

for a middle

Explain the divergence concretely: a component mounting during the render reads the store directly, while already-mounted components still hold the copy their subscription last wrote.

for a senior

Show the diagnostic instinct: intermittent mismatches that only appear under load or on slow devices, plus the structural insight that the pattern turns one shared fact into many independent state values.

for a principal

Own the migration argument — why this is a correctness constraint rather than a preference, how to make the unsafe path unavailable rather than merely discouraged, and what you accept for sources you choose not to migrate.

## The pattern under discussion ```js function useStoreValue(store) { const [value, setValue] = useState(() => store.getState()); useEffect(() => store.subscribe(() => setValue(store.getState())), [store]); return value; } ``` This was the standard way to connect a component to shared external state for years, and under synchronous rendering it was sound. Concurrent rendering breaks it, and understanding *why* is the point of the question — not memorising a replacement. ## Effects run after the commit, never inside the render The first thing to state plainly: React does not run effects between two components of a render pass. The order is render everything, commit, then run effects. So while the render is in flight — the exact window in which the store can move — the subscription mechanism is dormant. It has no opportunity to reconcile anything, because it is not running. That single fact rules out this pattern as a fix. Whatever the subscription does, it does it after the inconsistent tree has already been assembled. ## Failure one: seeding versus mirroring The hook has two different paths to a value, and they are read at different times: - A component that is **already mounted** holds a copy in state, last written by the subscription callback. - A component that **mounts during this render** runs the lazy initializer and reads the store directly, at the instant its own body executes. Now suppose the store changes partway through a render that mounts a new component: ``` render <Total/> (already mounted, state copy holds 3) | | React yields; something calls store.setState(4) v render <Badge/> (mounting now: useState(() => store.getState()) -> 4) commit Total shows 3, Badge shows 4 ``` Both components used the same hook, and the commit is internally contradictory. The subscription will fire afterwards and eventually align them, but a torn frame was already shown. ## Failure two: updates from a subscription have no special standing When the subscription callback calls the setter, that is an ordinary state update. React treats it exactly like an update from a click handler or a timer — it can be batched with others, and it is scheduled according to normal priority rules. Nothing about it tells React "several components are mirroring one shared fact and must all move together." React has no way to know that the copy inside `Total` and the copy inside `Badge` are supposed to be the same number, because as far as the reconciler is concerned they are two unrelated pieces of component state that happen to hold equal values most of the time. That is the deeper problem. The pattern turns *one* shared fact into *N* independent copies, and consistency across N independent state values is not something React ever promised. ## Failure three: nothing is verified before commit A tearing-safe read works because React records what the component rendered with and re-checks it before putting the tree on screen. The mirroring pattern gives React nothing to check. From the reconciler's point of view, the value came out of ordinary state, which it already considers trustworthy. There is no signal that this particular state is a shadow of something that lives elsewhere and may have moved. There is also a related gap in the same pattern — the store can change between the render that read it and the moment the effect actually subscribes, so an update can be missed entirely. That is a subscription-setup concern rather than a tearing concern, but it comes from the same root cause: the effect runs too late to cover the render. ## What actually changes with a tearing-safe read The substantive difference is not *where the subscription lives* but *whether the render-time read is visible to React*. Once React knows a component's output was derived from an external value, it can: - record the exact value the component rendered with; - re-read it before commit and compare; - discard and redo the render if it moved, rather than committing a mixed tree; - treat all components reading that store as participants in one update instead of N unrelated ones. None of that is achievable from inside an effect, at any level of cleverness, because effects are outside the render. ## How to answer this out loud Lead with the timing: effects run after commit, so the subscription cannot influence the render where the inconsistency forms. Then give the concrete divergence — new mounts read the store directly while existing components hold a copy. Then land the general point: the pattern replaces one shared fact with many independent state copies, and React never guaranteed that independent state values stay in step.

  • Would moving the subscription into useLayoutEffect fix it?
    No. useLayoutEffect still runs after the commit, just earlier than a passive effect and before the browser paints. It can shrink the visible window in some cases, but it is still outside the render pass where the divergence formed, and it does nothing about new mounts seeding directly from the store.
  • Under what conditions does this pattern behave perfectly well?
    When rendering never yields mid-pass — the old synchronous model — or when the store simply does not change during the brief window of a render. That is why teams often ship this pattern for years without incident and then see rare, unreproducible inconsistencies after adopting transitions or under heavy load on slow devices.
  • Why does having N copies of one value matter, if they all get updated?
    Because React guarantees consistency of state within one render pass, not equality across unrelated state values. Once the shared fact is copied into many components, keeping them equal depends on every copy being written in the same pass — which nothing enforces. Keeping one read of one source removes the class of problem.
  • How would this show up in a bug report?
    As an intermittent, hard-to-reproduce mismatch: a count that disagrees with the list beside it, a header that contradicts a badge, usually reported on slower machines or under load and never reproducible locally. The screenshot shows something impossible, and by the time anyone looks, the next render has healed it.

saying these in an interview costs you the question

  • Effects run between components, so the subscription can catch it
  • useLayoutEffect fixes tearing because it runs earlier
  • Adding the store to the dependency array solves the inconsistency
  • It is safe as long as every component uses the same hook
  • Copying the store value into state makes it React state, so it cannot tear

context