External Stores and Tearing
What you'll learn
- What "tearing" is, and why interruptible rendering makes it possible
- Why tearing could never happen with synchronous rendering
- How
useSyncExternalStoreguarantees a tear-free screen - The
subscribe/getSnapshotcontract, and thegetSnapshottrap - Building a tiny store by hand, then reading it the React-safe way
Concurrent rendering lets React pause mid-render. So far that's been safe, because everything a render reads, props, state, context, comes from React's own private draft tree. But what about state React doesn't control: a Redux store, a zustand store, a plain global object updated by a websocket? Pausing creates a window of time, and the outside world can change inside that window. This chapter is about what goes wrong, and the hook that fixes it.
The setup: reading a store during render
An external store is any mutable state that lives outside React. Here's a complete one in a dozen lines, no React involved:
// priceStore.js — a plain external store
let state = { price: 10 };
const listeners = new Set();
export const priceStore = {
get() {
return state;
},
set(next) {
state = next;
listeners.forEach((listener) => listener());
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
Three methods: get reads, set writes and notifies, subscribe registers a listener. Now the naive way to read it, directly, during render:
import { priceStore } from './priceStore';
function PriceHeader() {
const { price } = priceStore.get(); // read directly during render
return <h2>Total: ${price}</h2>;
}
function PriceFooter() {
const { price } = priceStore.get();
return <footer>You pay: ${price}</footer>;
}
export default function App() {
return (
<div>
<PriceHeader />
<PriceFooter />
<button onClick={() => priceStore.set({ price: 12 })}>
Apply coupon
</button>
</div>
);
}
Two components, both reading the same store, both supposed to agree forever. Watch what happens when a render gets interrupted between them.
The tear, step by step
Jargon: "tearing". A committed screen that shows two different values for the same piece of state, because the state changed while the render that produced the screen was paused.
What happens:
- Something schedules a time-sliced render of
App(say a transition from earlier in the tree). - The render begins.
PriceHeaderruns and readspriceStore.get().price→ $10. Its units of work complete. - The 5ms slice ends (chapter 2). React yields to the browser. The render is paused mid-tree.
- During the pause, the outside world moves: a websocket tick, another click, anything, calls
priceStore.set({ price: 12 }). - The render resumes.
PriceFooterruns and reads the store → $12. - The render completes and commits. One screen now shows "Total: $10" and "You pay: $12". That's a tear.
No error is thrown. Nothing retries. The screen is simply, silently wrong, showing a combination of values that never existed at any single moment in time.
Why this could never happen synchronously
In the old, uninterruptible world, a render is one atomic block (chapter 1). Nothing, not a websocket, not a click, can run set during it, because nothing else can run at all. Every component in the render reads the store within the same unbroken instant, so they all agree by construction. Time slicing created the window; tearing crawls through it.
Notice, too, why React's own state is immune: useState values aren't read from a mutable global, they're captured per render attempt from the private draft tree. A paused render still sees its own version of every state variable. Only external reads escape that protection.
useSyncExternalStore: the fix
Jargon: "useSyncExternalStore". The hook for reading external mutable stores safely. You hand it two functions, how to subscribe to changes and how to read the current snapshot, and it guarantees every component on a committed screen saw the same value.
const value = useSyncExternalStore(subscribe, getSnapshot);
Jargon: "snapshot". The value of the store at one instant, as returned by
getSnapshot. React treats it as an immutable fact about the world and compares snapshots withObject.is.
The hook buys consistency with three guarantees:
- Record. During render, React reads
getSnapshot()and records the result for this render attempt. - Re-check. After the render finishes but before committing, React calls
getSnapshot()again. Different from what was recorded? The world changed mid-render → discard the render and re-render synchronously, no time slicing, no yields, no window, so the second pass cannot tear. - Sync updates. When the store notifies a change through
subscribe, React schedules the re-render as synchronous, non-time-sliced work. Store-driven updates deliberately skip the machinery that creates the window.
Either the whole screen agrees, or React pays for a blocking do-over. Consistency over politeness, exactly the right trade for facts like prices.
The getSnapshot trap
getSnapshot must return the same value, by Object.is, every time it's called, until the store actually changes. React calls it repeatedly (during render, before commit) and interprets any difference as "the store changed". Return a fresh object each call and you've built an infinite loop:
import { useSyncExternalStore } from 'react';
import { priceStore } from './priceStore';
export default function BadPrice() {
const snapshot = useSyncExternalStore(
priceStore.subscribe,
() => ({ price: priceStore.get().price }) // BAD: fresh object every call
);
return <h2>Total: ${snapshot.price}</h2>;
}
What happens:
- React reads the snapshot:
{ price: 10 }. - Before committing, it re-checks: a new
{ price: 10 }, different object identity, soObject.issays "changed!". - React discards and re-renders. The re-check produces another fresh object. "Changed!" again.
- After enough rounds, React gives up and throws: "The result of getSnapshot should be cached to avoid an infinite loop."
The fix: return something stable, a primitive like the price number, or the exact object the store is holding (priceStore.get()), which only changes identity when the store really changes. If you must derive an object, cache it in the store itself and return the cached reference until the inputs change.
The contract, end to end
Same store, read the safe way:
import { useSyncExternalStore } from 'react';
import { priceStore } from './priceStore';
function usePrice() {
return useSyncExternalStore(
priceStore.subscribe, // how to listen
() => priceStore.get().price // how to read: a stable primitive
);
}
function PriceHeader() {
const price = usePrice();
return <h2>Total: ${price}</h2>;
}
function PriceFooter() {
const price = usePrice();
return <footer>You pay: ${price}</footer>;
}
export default function App() {
return (
<div>
<PriceHeader />
<PriceFooter />
<button
onClick={() =>
priceStore.set({ price: priceStore.get().price + 2 })
}
>
Price up
</button>
</div>
);
}
What happens:
- On mount, each component subscribes to the store through the hook.
- During render, both read the snapshot: $10, recorded for this attempt.
- Before commit, React re-checks: still $10 → commit. The screen is consistent.
- You click Price up.
setstores $12 and notifies listeners. - React schedules a synchronous re-render. Header and footer both read $12 within one uninterruptible render, no pause, no window.
- Commit: "Total: $12" and "You pay: $12". They can never disagree on a committed screen.
And the one-liner worth remembering: this is exactly what React-Redux's useSelector and zustand are built on. When you use those libraries, you're already using this hook, now you know the contract it's upholding for you.
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception: Tearing is a theoretical concern. Reality: any mutable external data, stores, globals, websocket-fed caches, read during render can tear once concurrent features interrupt that render.
- Misconception:
useSyncExternalStoreis only for library authors. Reality: app code with globals,windowflags, or hand-rolled event sources needs it just as much. - Misconception: A "getSnapshot should be cached" error means React is buggy. Reality: it almost always means
getSnapshotreturns a fresh object or array each call, return a stable reference or primitive. - Misconception: The synchronous re-render defeats concurrency. Reality: it's a narrow escape hatch, paid only when a store actually changes mid-render, a small price for a screen that can never contradict itself.
- Misconception:
useStateand context can tear too. Reality: no, React's own state is captured per render attempt from the private draft tree. Only external mutable reads need this protection.
Why it works this way
- React can't pause the outside world. A websocket won't wait for a render to finish. The only possible strategy is detect the change and redo the work.
- Re-check + synchronous redo = minimal blocking. The expensive path runs only when a tear would actually occur; ordinary renders pay one extra
getSnapshotcall. - Stable snapshots make checks cheap.
Object.ison a recorded value is nearly free, as long as the value is genuinely stable, hence the caching contract. - One contract, every store. Any object with
subscribe+getSnapshotbecomes React-safe, which is why every major state library converged on this hook.
Try it yourself
- Wire up the hand-rolled
priceStorewithusePriceand click Price up rapidly. Expected: header and footer always agree, on every single screen. - Break
getSnapshotto return a fresh object (() => ({ price: priceStore.get().price })). Expected: the "should be cached" infinite-loop error. Fix it by returning the primitive and confirm the error is gone. - Add
setInterval(() => priceStore.set({ price: priceStore.get().price + 1 }), 2000). Expected: the UI follows every two seconds, header and footer in lockstep, store-driven updates bypass time slicing. - Thought experiment: why doesn't
const [price] = useState(...)need any of this? Answer: the value is captured per render attempt from the draft tree, a paused render keeps its own version, so no window exists.
Recap
- Tearing = one committed screen showing two different values for the same external state, because the store changed while the render was paused.
- Synchronous rendering can't tear, nothing can mutate the store mid-render. Time slicing opens the window.
- React's own state is immune; only external mutable reads are exposed.
useSyncExternalStore(subscribe, getSnapshot)records the snapshot, re-checks it before commit (mismatch → synchronous redo), and schedules store-driven updates synchronously.getSnapshotmust return a stable value, a fresh object per call causes the infinite-loop error.- Redux's
useSelectorand zustand are built on exactly this hook.