Skip to the chapter
←ReactGGroundwork⚛React
React/Advanced/A9
42 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛A9 · Part 3 · Advanced

useSyncExternalStore

The correct way to read state that lives outside React.

5 min read10 sections2 exercises
42 of 57

In this chapter

  • The problem it exists for
  • The hook
  • Online status, correctly
  • A subscription that depends on a prop
  • Writing the store yourself

and 5 more

about 5 min
10On this page
  1. The problem it exists for
  2. The hook
  3. Online status, correctly
  4. A subscription that depends on a prop
  5. Writing the store yourself
  6. localStorage across tabs
  7. The URL is an external store with a gap
  8. What tearing actually looks like
  9. The costs you accept
  10. When to reach for it

The problem it exists for

Plenty of state lives outside React: localStorage, the URL, a websocket, window.matchMedia, online status, a Redux store. The obvious approach is an effect that subscribes and copies the value into useState.

const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
  const on = () => setOnline(true);
  window.addEventListener("online", on);
  return () => window.removeEventListener("online", on);
}, []);

Three problems with that, and the third only appears under concurrency.

  1. Between render and the effect running, the value may already be wrong.
  2. On the server there is no navigator, so the initial read crashes or has to be guarded.
  3. Tearing: during an interruptible render, the external value can change mid-render, so two components read different values and the screen shows an inconsistent mix.

There is a fourth, quieter one in that snippet: it never listens for offline, so once online it stays online. Copying external state into React state means writing every transition by hand.

The hook

const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
ArgumentMust
subscribe(cb)Register cb, return an unsubscribe. Must be stable, or React resubscribes every render.
getSnapshot()Return the current value. Must return a cached reference if nothing changed.
getServerSnapshot()The value during SSR and during hydration. Omit it and server rendering throws.

Online status, correctly

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

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

subscribe is defined at module scope, so it is the same function on every render. Defining it inside the component would make React unsubscribe and resubscribe constantly.

Notice what the callback does: nothing but tell React "something may have changed". React then calls getSnapshot itself and compares. The event's payload is irrelevant, which is why one callback can serve two events.

getSnapshot must be referentially stable
() => ({ width: window.innerWidth })         // ✗ new object every call → infinite loop
() => window.innerWidth                      // ✓ a primitive

React calls getSnapshot during render and compares the result with the last one using Object.is. A fresh object every time means "changed" every time, which means render again — forever. React warns "The result of getSnapshot should be cached". Return primitives, or cache the object and only replace it when the underlying data actually changes.

A subscription that depends on a prop

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

  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(query).matches,   // a boolean — stable by nature
    () => false
  );
}

const isWide = useMediaQuery("(min-width: 768px)");

When the subscription genuinely depends on an argument, it cannot live at module scope. useCallback keeps it the same function until the query changes, and then React resubscribes exactly once, which is what you want.

Writing the store yourself

function createStore(initial) {
  let state = initial;
  const listeners = new Set();

  return {
    getState: () => state,
    setState(update) {
      state = typeof update === "function" ? update(state) : update;   // replace, never mutate
      listeners.forEach((l) => l());
    },
    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

const cart = createStore({ items: [] });

function useCart(selector) {
  return useSyncExternalStore(cart.subscribe, () => selector(cart.getState()));
}

const count = useCart((s) => s.items.length);   // ✓ a number

Twenty lines, and it is the core of every store library. Two properties make it correct: setState replaces the state object rather than editing it, so an unchanged state really is the same reference; and the selector returns something stable. A selector like (s) => s.items.filter(...) builds a new array every call and loops, which is why libraries add a memoised selector layer on top.

localStorage across tabs

function subscribe(cb) {
  window.addEventListener("storage", cb);     // fires in *other* tabs
  return () => window.removeEventListener("storage", cb);
}

function useStoredValue(key) {
  return useSyncExternalStore(
    subscribe,
    () => localStorage.getItem(key),
    () => null
  );
}

The storage event does not fire in the tab that wrote the value, so a complete implementation keeps its own subscriber list and notifies it on write — exactly the store module above. getItem returns a string, which compares by value, so this snapshot is stable; parse it with JSON.parse inside getSnapshot and it no longer is.

The URL is an external store with a gap

function subscribe(cb) {
  window.addEventListener("popstate", cb);       // back and forward buttons only
  window.addEventListener("app:navigate", cb);   // our own pushes
  return () => {
    window.removeEventListener("popstate", cb);
    window.removeEventListener("app:navigate", cb);
  };
}

export function navigate(to) {
  history.pushState(null, "", to);
  window.dispatchEvent(new Event("app:navigate"));   // pushState fires nothing
}

const pathname = useSyncExternalStore(subscribe, () => location.pathname, () => "/");

popstate fires for back and forward, but not when your own code calls history.pushState. A hook that only listens to popstate works in every manual test that uses the back button and goes stale the first time the app navigates itself. Every external source has a gap like this — the storage event skipping the writing tab is the same bug — and the fix is always to route your own writes through something that notifies. This is a small version of what a router does for you.

What tearing actually looks like

React renders a list of prices at low priority. Halfway through, a websocket updates the store. With the effect-and-state approach, components rendered before the update show the old price and components after it show the new one — one screen, two versions of the truth. useSyncExternalStore detects the change and re-renders from a consistent snapshot.

This is not theoretical: it is why every state library moved to this hook when React 18 shipped. Redux, Zustand and Jotai all use it internally — which is the main reason you rarely call it yourself.

The costs you accept

  • Store updates are never transitions. For a transition, React checks getSnapshot once more just before committing. If the store changed in the meantime, React throws the work away and renders again as a blocking update, so every component shows the same version.
  • Do not suspend on a store value. Because a store change cannot be a transition, suspending on it replaces content already on screen with the nearest fallback.
  • Hydration uses the server snapshot. The first client render must match the HTML, so it reads getServerSnapshot, then re-renders with the real value. A width or theme that differs on the client will visibly change after load; design the server value to be the common case.

When to reach for it

  • Wrapping a browser API — media queries, online status, scroll position, document.visibilityState.
  • Integrating a non-React store: a legacy event emitter, a third-party SDK.
  • Writing a state library.

Not for data you fetch — that is a query cache — and not for state React already owns.

Interview answer, one sentence

"It is the supported way to subscribe to state outside React, and it exists because the effect-and-state pattern can tear under concurrent rendering — two components reading different values of the same store in one render. The things that catch people are that getSnapshot must return a stable reference, subscribe must be stable, and the server snapshot is also what hydration renders."

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 4 testsSubscribe to a browser API that changes outside Reactadvanced
  • 6 testsSubscribe to a key being held downadvanced
All 54 for this level →
A9

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. A8Previous · A8Effect timing
  2. A9you are here
  3. Next · A10Refs in depthForwarding, imperative handles, and integrating libraries that own their own DOM.4 min read

01On this page

10 sections · 0%

  1. 01The problem it exists for
  2. 02The hook
  3. 03Online status, correctly
  4. 04A subscription that depends on a prop
  5. 05Writing the store yourself
  6. 06localStorage across tabs
  7. 07The URL is an external store with a gap
  8. 08What tearing actually looks like
  9. 09The costs you accept
  10. 10When to reach for it

[] chapters · t top