open atlas
↑ Back to track
React, zero to senior RCT · 06 · 03

External stores: useSyncExternalStore, selector subscriptions, and tearing

useSyncExternalStore is the contract for reading state React does not own: subscribe + cached getSnapshot, with tearing checks. Stores like zustand build selectors on it — no provider, re-render only when your slice changes — and beat context for write-heavy, cross-tree state.

RCT Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

The trading dashboard had a bug that screenshots couldn’t prove and QA couldn’t script: occasionally the portfolio header showed a total that didn’t equal the sum of the visible rows. By the time anyone clicked, it was correct again. The team’s hand-rolled store hook — useEffect to subscribe, useState to mirror the value — had worked for three years. What changed was a React 18 upgrade and a startTransition around a heavy filter. Now renders were interruptible: the header read the store at the start of a long render pass, a price tick mutated the store mid-render, and the rows read the new value when rendering resumed. One render, two versions of the truth — tearing. The screenshot finally came from a Bloomberg-obsessed customer with a 144 Hz monitor. The fix was deleting forty lines of subscription code for one useSyncExternalStore call — React’s contract for exactly this problem: it checks the snapshot after rendering and re-renders synchronously if the store moved underneath it. The bug was unreproducible because it required losing a race nobody knew was being run.

By the end of this lesson you’ll understand why upgrading to React 18 can silently break a store hook that worked for years — and exactly what the API contract requires to prevent it.

The contract: subscribe, getSnapshot, and the tearing check

State React owns (useState, useReducer) is versioned with the fiber tree, so concurrent rendering can never see two versions in one pass. State living outside React — a module singleton, a WebSocket cache, navigator.onLine — has no such guarantee: an interruptible render can span a mutation, and components rendered before and after it disagree in the same committed frame. That is tearing, and useSyncExternalStore is the contract that prevents it:

const value = useSyncExternalStore(
  subscribe,    // (callback) => unsubscribe; call callback on every store change
  getSnapshot,  // () => current value; MUST return cached value if nothing changed
  getServerSnapshot // optional: value for SSR + hydration
);

subscribe registers React’s own change-callback with your store and returns a cleanup. getSnapshot returns the current value — and must return the same reference until the store actually changes, because React calls it repeatedly and uses Object.is to decide if anything moved. Return a fresh object each call (() => ({ ...state })) and React sees an eternally-changing store: the infamous “The result of getSnapshot should be cached” error, followed by an infinite re-render loop. The anti-tearing mechanism: when a store update arrives during a non-blocking render, React re-checks snapshots and forces the conflicting update to render synchronously, sacrificing time-slicing for consistency. Your old useEffect-subscription hook had a second flaw besides tearing: updates firing between render and effect-subscription were silently lost — uSES closes that gap too.

Zustand: selector subscriptions on top of the contract

useSyncExternalStore is deliberately low-level — one store, whole-value subscription. Zustand is what the pattern looks like productized. The store is created outside React as a module-level singleton — no provider, no context, importable from anywhere including non-React code:

const useCartStore = create((set, get) => ({
  items: [],
  promo: null,
  addItem: (item) => set((s) => ({ items: [...s.items, item] })),
  total: () => get().items.reduce((sum, i) => sum + i.price, 0),
}));

// Component subscribes to a SLICE via selector:
function CartBadge() {
  const count = useCartStore((s) => s.items.length);
  return <span>{count}</span>; // re-renders ONLY when items.length changes
}

The mechanism is selector subscription: the hook runs your selector against the snapshot, and re-renders the component only when the selector’s output changes (Object.is by default; a custom equality like shallow-compare is opt-in). A promo-code write re-renders zero components that only selected items.length. Compare context: every consumer re-renders on any change to the value’s identity, full stop. The classic self-inflicted wound is a selector returning a fresh object — useCartStore((s) => ({ a: s.a, b: s.b })) — new reference every snapshot, re-render on every store write, the precision silently gone (fix: two selectors, or shallow equality).

When external beats context — and when state should bypass React entirely

Before reaching for an external store, ask: how many consumers re-render per write, and how often do writes happen? The decision is not fashion; each side has a mechanism. Write-heavy state: context re-notifies every consumer per write; a store notifies only matching selectors — at 20 writes/second with 50 consumers, that is 1,000 component renders per second versus a handful. Cross-tree access: a store is a module import — event handlers, route loaders, analytics, and code outside the provider hierarchy can read and write it; context requires being under the provider. Tooling: middleware (persist, immer, devtools with action history) attaches at the store boundary. Context still wins for low-write, scope-shaped values — theme, locale, the current user — where the provider hierarchy is the feature and a dependency is overhead.

lesson.inset.how

Transient updates are the senior move for 60 Hz data: subscribe imperatively and skip rendering altogether. useCartStore.subscribe(...) (or zustand’s subscribeWithSelector middleware) inside an effect, writing to a ref or directly mutating a DOM node — a cursor position, a live chart, a drag ghost — updates at frame rate with zero reconciliation. React renders the component once; the store drives the pixels. The rule of thumb: if the value changes faster than the user can read it, it probably should not be React state at all.

Quiz

A getSnapshot implemented as () => ({ ...store.state }) triggers the error 'The result of getSnapshot should be cached' and an infinite loop. What is the mechanism?

Quiz

A dashboard receives ~20 store writes per second and has ~50 subscribed components, each caring about a different slice. Why does a zustand-style store fundamentally outperform context here?

Recall before you leave
  1. 01
    Explain tearing: why can a hand-rolled useEffect-plus-useState store subscription show two versions of the store in one frame under concurrent React, and how does useSyncExternalStore prevent it?
  2. 02
    Walk the decision: context versus a zustand-style external store versus a transient subscription — what mechanism makes each the right tool, and for which state?
Recap

External state — anything living outside useState/useReducer — breaks an invariant React otherwise guarantees: one render pass, one version of every value. Concurrent rendering makes the break visible as tearing: an interruptible render spans a store write, and components committed in the same frame disagree. useSyncExternalStore is the contract that restores consistency: subscribe gives React a change-callback into your store; getSnapshot returns the current value and must keep returning the same reference until a genuine change, because React compares snapshots with Object.is — a fresh object per call means the cached-snapshot error and an infinite loop. When a write lands mid-render, React detects the mismatch and re-renders synchronously, deliberately sacrificing time-slicing for correctness; it also closes the lost-update gap of effect-based subscriptions. Zustand is this contract productized: stores are module singletons created outside React — no provider, importable from event handlers and non-React code — and components subscribe through selectors, re-rendering only when the selected output changes. That selector precision is the quantitative case against context for write-heavy state: notify matching slices, not every consumer. Context keeps the low-write, scope-shaped jobs. And for data changing at frame rate, skip rendering entirely: transient subscriptions write to refs or DOM nodes from an imperative subscribe, letting the store drive pixels while React renders once. Now when you see a dashboard where unrelated components flicker on every tick, you’ll reach for selectors first — and know that the classic foot-gun is a selector that returns a new object every call.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.