Ch. 3 · React

React External Stores and Consistent Snapshots

How useSyncExternalStore subscribes React to an external store without tearing, why getSnapshot must be cached, and how server snapshots work.

~6 min readadvancedupdated Oct 6, 2026

“How would you connect a non-React store, like a WebSocket feed or localStorage, to your components?” Older answers use useState plus a useEffect subscription. Interviewers for senior roles want to hear useSyncExternalStore, and more importantly why: concurrent rendering can otherwise show inconsistent values across the screen, a problem called tearing.

This is also how libraries such as Redux (via react-redux) and Zustand connect to React today, so explaining it shows you understand what happens beneath the state library you use daily.

Before you start

You should know useEffect subscriptions with cleanup (see effect cleanup), closures, and the idea that React 18 introduced concurrent rendering, where React can pause a render and resume it later. Familiarity with the observer pattern (subscribe, notify, unsubscribe) helps. The hook was added in React 18 and is unchanged in React 19.

The short answer

useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?) lets a component read from a store that lives outside React. subscribe(callback) registers a listener and returns an unsubscribe function; getSnapshot() returns the current value. React calls getSnapshot during render and again before committing, and if the store changed mid-render it re-renders synchronously so every component shows the same version. getSnapshot must return a cached value (the same reference until the store actually changes), otherwise React sees a new value every time and loops. getServerSnapshot supplies the value for server rendering and hydration.

How it works

A minimal store needs three things: a current value, a set of listeners, and a way to notify them:

function createStore(initial) {
  let state = initial;
  const listeners = new Set();
  return {
    getSnapshot: () => state,
    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
    setState(update) {
      const next = typeof update === 'function' ? update(state) : update;
      if (Object.is(next, state)) return;
      state = next;
      listeners.forEach(l => l());
    },
  };
}
JavaScript

Immutable updates make getSnapshot naturally cached: it returns the same object until setState replaces it. A component reads it like this:

const counterStore = createStore({ count: 0 });

function Count() {
  const state = useSyncExternalStore(counterStore.subscribe, counterStore.getSnapshot);
  return <p>{state.count}</p>;
}
JSX

Why not useState plus useEffect? During a concurrent render, React may render half the tree, yield to the browser, and continue. If the store changes in between, components rendered before the change show the old value and those after show the new one. That is tearing. useSyncExternalStore checks the snapshot again before commit and forces a synchronous, consistent render if it changed. The effect approach also renders once with the initial value and then again after subscribing, and can miss an update that happens between render and subscription.

Step-by-step walkthrough

Step 1: Keep subscribe stable

If you define subscribe inline in the component, it is a new function every render and React will unsubscribe and resubscribe each time. Define it at module level or wrap it in useCallback:

function subscribeOnline(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

function useOnlineStatus() {
  return useSyncExternalStore(
    subscribeOnline,
    () => navigator.onLine,
    () => true // server: assume online
  );
}
JSX

navigator.onLine is a boolean, so the snapshot is trivially stable.

Step 2: Return cached snapshots, never fresh objects

This selector creates a new object every call:

// Loops: "The result of getSnapshot should be cached"
const view = useSyncExternalStore(store.subscribe, () => ({
  count: store.getSnapshot().count,
}));
JSX

React compares consecutive snapshots with Object.is, sees a difference every time, and re-renders until it throws maximum update depth exceeded. Select a primitive or an existing reference instead:

const count = useSyncExternalStore(store.subscribe, () => store.getSnapshot().count);
JSX

Step 3: Handle sources that do not return stable values

localStorage.getItem returns a string, which compares by value, so it is fine. A source like JSON.parse(localStorage.getItem('prefs')) produces a new object every call; cache it by the raw string:

let lastRaw, lastParsed;
function getPrefsSnapshot() {
  const raw = localStorage.getItem('prefs');
  if (raw !== lastRaw) {
    lastRaw = raw;
    lastParsed = raw ? JSON.parse(raw) : {};
  }
  return lastParsed;
}
JavaScript

Step 4: Provide a server snapshot

Without getServerSnapshot, server rendering throws for this hook. The server snapshot must return the same value on the server and during client hydration; if the real client value differs, React renders the server value for hydration and then updates. That avoids a hydration mismatch.

Worked scenario

A trading dashboard subscribed to a price feed in each widget with useState and useEffect. During heavy updates, a header total and a table row occasionally disagreed for a moment, and screenshots in bug reports showed a total that did not match the visible rows. Under React 18’s concurrent features (a transition was used for switching tabs), parts of the tree rendered at different times and read different prices.

The team created one store for the feed and replaced each widget’s subscription:

const price = useSyncExternalStore(
  priceStore.subscribe,
  () => priceStore.getSnapshot().prices[symbol]
);
JSX

The first deploy froze the tab: one widget used () => ({ ...priceStore.getSnapshot().prices }) to copy the map, creating a new object per call. Returning the store’s existing prices object (replaced immutably on each tick) fixed it. Totals and rows now always matched because React guarantees a consistent snapshot per commit.

Common mistake

  • Returning a new object or array from getSnapshot. The most common bug; it causes infinite re-renders.
  • Inline subscribe. Correct but wasteful; React resubscribes on every render.
  • Mutating store state in place. getSnapshot returns the same reference, so React never sees the change.
  • Using it for state React owns. If the data originates in React components, useState, useReducer or context are simpler.
  • Forgetting getServerSnapshot in server-rendered apps. Rendering fails on the server without it.

Verify the behavior

Test the store in Node first:

const store = createStore({ count: 0, user: 'ada' });
let calls = 0;
const unsub = store.subscribe(() => calls++);
store.setState(s => ({ ...s, count: s.count + 1 }));
store.setState(s => s); // no change, no notification
console.log(store.getSnapshot(), calls); // { count: 1, user: 'ada' } 1
unsub();
store.setState(s => ({ ...s, count: 5 }));
console.log(calls); // 1
JavaScript

Then render two components reading the same store, update it from a button inside startTransition, and confirm in React DevTools that both always show the same value. To see the guard, return { ...store.getSnapshot() } from getSnapshot and watch React log that the result should be cached, then throw.

Follow-up questions

What is tearing? Showing different versions of the same data in one committed UI, possible when a concurrent render reads mutable external data at different times.

Does useSyncExternalStore work with transitions? Store updates are always synchronous; they cannot be marked as non-blocking transitions. If a store update would suspend, React shows the nearest fallback instead of keeping old content.

How do libraries add selectors? They wrap the hook with a selector and an equality check that caches the selected value, such as useSyncExternalStoreWithSelector from the use-sync-external-store package.

Why not context for a high-frequency feed? Context re-renders every consumer on any change; a store with selectors re-renders only components whose selected slice changed. See context scope.

Interview exercise

Write a useMediaQuery(query) hook with useSyncExternalStore that works with server rendering. State one subtle issue to watch for.

Answer and reasoning

function useMediaQuery(query) {
  const subscribe = useCallback(callback => {
    const mql = window.matchMedia(query);
    mql.addEventListener('change', callback);
    return () => mql.removeEventListener('change', callback);
  }, [query]);

  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(query).matches,
    () => false
  );
}
JSX

subscribe is memoised on query so React resubscribes only when the query changes. The snapshot is a boolean, so it is stable. The server snapshot returns false, and the subtle issue is that on a device where the query matches, hydration renders false first and then switches to true, so layout may shift once. Choose a server default that matches most users, or make the affected UI non-critical. Calling matchMedia in getSnapshot on every render is cheap enough, but you could cache the MediaQueryList per query.

Continue learning

Explore the React interview questions and the React MCQs. Related notes: effect cleanup, context scope and transitions and urgent updates. Official reference: the useSyncExternalStore reference on react.dev.

More in React

read ✓React · mid

React Deferred Results and Stale UI

Keep a search input responsive with useDeferredValue, show users when results are stale, and see why memo is required and fetches aren't reduced.

~6 min readread →
read ✓React · mid

React Transitions and Urgent Input Updates

useTransition explained: keep typing responsive by marking heavy renders as non-urgent, why input state stays urgent, and useDeferredValue.

~6 min readread →
esc