Skip to main content

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 useSyncExternalStore guarantees a tear-free screen
  • The subscribe / getSnapshot contract, and the getSnapshot trap
  • 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:

  1. Something schedules a time-sliced render of App (say a transition from earlier in the tree).
  2. The render begins. PriceHeader runs and reads priceStore.get().price$10. Its units of work complete.
  3. The 5ms slice ends (chapter 2). React yields to the browser. The render is paused mid-tree.
  4. During the pause, the outside world moves: a websocket tick, another click, anything, calls priceStore.set({ price: 12 }).
  5. The render resumes. PriceFooter runs and reads the store → $12.
  6. 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 with Object.is.

The hook buys consistency with three guarantees:

  1. Record. During render, React reads getSnapshot() and records the result for this render attempt.
  2. 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.
  3. 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:

  1. React reads the snapshot: { price: 10 }.
  2. Before committing, it re-checks: a new { price: 10 }, different object identity, so Object.is says "changed!".
  3. React discards and re-renders. The re-check produces another fresh object. "Changed!" again.
  4. 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:

  1. On mount, each component subscribes to the store through the hook.
  2. During render, both read the snapshot: $10, recorded for this attempt.
  3. Before commit, React re-checks: still $10 → commit. The screen is consistent.
  4. You click Price up. set stores $12 and notifies listeners.
  5. React schedules a synchronous re-render. Header and footer both read $12 within one uninterruptible render, no pause, no window.
  6. 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

Rendered diagram (PNG hi-res):

rendered diagram

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: useSyncExternalStore is only for library authors. Reality: app code with globals, window flags, 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 getSnapshot returns 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: useState and 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 getSnapshot call.
  • Stable snapshots make checks cheap. Object.is on 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 + getSnapshot becomes React-safe, which is why every major state library converged on this hook.

Try it yourself

  1. Wire up the hand-rolled priceStore with usePrice and click Price up rapidly. Expected: header and footer always agree, on every single screen.
  2. Break getSnapshot to 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.
  3. 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.
  4. 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.
  • getSnapshot must return a stable value, a fresh object per call causes the infinite-loop error.
  • Redux's useSelector and zustand are built on exactly this hook.

Next

Suspense: the mental model →