Command Palette

Search for a command to run...

Hectal
PHASE 10Intermediate React 18+ ~31 min· topic 3 of 6

Topic 10.3

useSyncExternalStore: Subscribing to Outside Stores

In one line

useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?) reads a value that lives outside React (a browser API or a store library) and re-renders when it changes, without the tearing bugs that a hand-made effect-and-state subscription can have.

Think of it like this

A railway station's departure board. You don't phone the station every minute to ask about your train. You subscribe: the board updates itself, and whenever you glance at it you get the current snapshot of the timetable. Everybody in the station sees the same board at the same moment, so two passengers never read two different departure times. useSyncExternalStore gives React exactly this: subscribe to changes, read the current snapshot, and make sure the whole screen agrees.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

External store
Data that lives outside React and can change by itself, like navigator.onLine or a Zustand store.
subscribe
Your function that starts listening for changes, calls React's callback on each change, and returns a function that stops listening.
getSnapshot
Your function that returns the store's current value. It must return the same value until the store really changes.
Snapshot
The value of the store at one moment, as React reads it during a render.
Tearing
Different parts of the screen showing different versions of the same data at the same time.
Concurrent rendering
React's ability (since React 18) to pause a render, handle something urgent, and continue later.
getServerSnapshot
The value to use during server rendering and hydration, where browser APIs don't exist.
Hydration
React attaching event handlers and state to HTML that a server already rendered (Topic 20.6).

Step by step

01Write subscribe outside the component

subscribe receives React's callback and must return an unsubscribe function. Define it outside the component (or keep it stable with useCallback): if it's a new function each render, React unsubscribes and subscribes again on every render.

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

02Return a cheap, stable snapshot

navigator.onLine is a boolean, so it's stable by nature. Booleans, numbers and strings compare by value; objects compare by identity.

useOnlineStatus.jswhole filejsx
const getSnapshot = () => navigator.onLine;
const getServerSnapshot = () => true; // assume online on the server

export function useOnlineStatus() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

03What React does with them

React reads the snapshot while rendering, subscribes after commit, and on every callback reads the snapshot again. Only a different snapshot causes a re-render. Before commit, it checks once more that the snapshot hasn't changed during the render.

What React does with themdiagram
Rendering diagram…

04Build your own store

A store needs three things: the current state, a way to change it that replaces the state object and notifies listeners, and a subscribe. Because setState creates a new object only on change, getState is naturally a cached snapshot.

createStore.jswhole filejsx
export function createStore(initial) {
  let state = initial;
  const listeners = new Set();
  return {
    getState: () => state,
    setState(update) {
      state = { ...state, ...update(state) }; // a new object only when we change it
      listeners.forEach((l) => l());
    },
    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

05Derived values: select a primitive or cache

Selecting store.getState().cart.length returns a number, which is safe. Selecting getState().items.filter(...) makes a new array every call and loops. Either keep the derived value in the store, select primitives, or memoise (React's own use-sync-external-store/with-selector package does this for libraries).

06Tearing, and how the hook prevents it

In a concurrent render React can yield between components. If a mutable store changes in that gap, part of the tree read the old value. useSyncExternalStore notices the snapshot changed before commit and re-renders synchronously, so the screen shows one value.

Tearing, and how the hook prevents itdiagram
Rendering diagram…

Try it yourself

  1. 1

    Add a third reader

    In the online example, add a component OfflineBanner that returns null when online and a red banner when offline, using the same useOnlineStatus(). Predict: does the store need any change? (No: every subscriber is notified.)

  2. 2

    Select an object and watch it break

    In the cart example, change CartBadge to const { items } = useCart((s) => ({ items: s.items }));. Predict the console, then run. Then fix it by selecting s.items again, or by selecting the whole s (the store's own object, which is stable).

  3. 3

    Fix the uncached snapshot

    In the third example, make getSnapshot return user, and add a function rename(name) { user = { ...user, name }; listeners.forEach((l) => l()); }. Call setTimeout(() => rename("Ravi"), 200) and check the page updates.

Code & diagrams

useOnlineStatus with useSyncExternalStore New tab

Firing a fake `offline` event doesn't change the real `navigator.onLine`, so the demo replaces the getter. That shows the point: React re-reads `getSnapshot` on each event, and both components update together.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Online / Save
Offline / Reconnecting...
Online / Save
Your own store, changed from outside React New tab

Each component re-renders only when **its** selected value changes: adding an item doesn't re-render `CouponLine`. This is the core of how Zustand and react-redux work (Phase 15).

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

CartBadge rendered: 0
CouponLine rendered: (none)
CartBadge rendered: 1
CouponLine rendered: HECTAL10
The uncached snapshot bug New tab

React compares snapshots with `Object.is`. A new object is never equal to the last one, so React keeps re-rendering until it gives up. Fix: `return user;` and replace `user` with a new object only when it changes.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

The result of getSnapshot should be cached to avoid an infinite loop
Uncaught Error: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.
An error occurred in the <Profile> component.

Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.
getServerSnapshot: rendering where there is no browser New tab

`renderToString` runs here in the browser, but it follows the server rules: React uses `getServerSnapshot` and throws without it. In a real app the server HTML says Light, and a dark-mode user sees it switch right after hydration (Topic 24.10 shows how to avoid that flash). The `<!-- -->` comment is how React's server HTML separates two pieces of text.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Without getServerSnapshot: Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering.
With getServerSnapshot: <p>Light<!-- --> theme</p>

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Return a new object from getSnapshot

Run the third live example as written: getSnapshot returns { ...user }.

terminal
$ Console
── what you'll see ──
The result of getSnapshot should be cached to avoid an infinite loop
Uncaught Error: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.

Break #2

Define subscribe inside the component

In the online example, move function subscribe(callback) { ... } inside useOnlineStatus and add console.log("subscribe") as its first line.

terminal
$ Console
── what you'll see ──
subscribe
subscribe
Online / Save
subscribe
subscribe
Offline / Reconnecting...
subscribe
subscribe
Online / Save

Myth vs fact

Myth

useSyncExternalStore is only for library authors.

Fact

Libraries are its main users, but it's also the right way for app code to read browser APIs like navigator.onLine, matchMedia and localStorage across tabs.

Myth

useState plus useEffect is just as good for subscriptions.

Fact

It mostly works, but it can tear under concurrent rendering, can miss a change between render and subscription, and needs extra care for server rendering. useSyncExternalStore handles all three.

Myth

Sync means it is slow.

Fact

"Sync" means React won't render store updates in a pausable way and re-renders synchronously if the store changes mid-render. Reading the snapshot itself is cheap; keep getSnapshot fast.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    In development React calls getSnapshot twice and compares the results to detect uncached snapshots. That's why the warning appears even before any store change.

  • ▸

    Store updates can't be wrapped in startTransition to stay non-blocking: if the store changes during a transition render, React restarts synchronously. For expensive derived UI, render from useDeferredValue(storeValue) (Topic 16.7) instead.

  • ▸

    If subscribe changes identity, React resubscribes; if getSnapshot changes identity, React just calls the new one on the next render. Selector-style hooks (useStore(selector)) pass an inline getSnapshot, which is fine as long as the result is stable.

  • ▸

    For React 16.8 and 17, the use-sync-external-store/shim package provides the same API. use-sync-external-store/with-selector adds a selector plus an isEqual comparator that memoises derived snapshots; react-redux's useSelector is built on it.

Remember this

  1. 1

    An external store is any data that lives outside React and can change by itself: navigator.onLine, window.matchMedia, localStorage, the URL, a WebSocket's latest message, or a state library such as Zustand or Redux (Phase 15). React's own state (useState, useReducer) lives inside React; an external store doesn't, so React can't see when it changes unless you tell it.

  2. 2

    The hook takes two functions. **subscribe(callback)** starts listening for changes, calls callback whenever the store changes, and returns an unsubscribe function (just like an effect cleanup). **getSnapshot() returns the store's current value, called a snapshot**. React calls subscribe after the first commit, calls getSnapshot during every render, and when callback fires it calls getSnapshot again: if the result differs from the last one (compared with Object.is), the component re-renders.

  3. 3

    Snapshots must be cached. getSnapshot is called often, and React compares results by identity. If it returns a new object or array every call (return { ...user } or items.filter(...)), React thinks the store changed every time, re-renders, gets yet another new object, and loops. In development React warns The result of getSnapshot should be cached to avoid an infinite loop and then fails with Maximum update depth exceeded. Return the same object until the data really changes: return a primitive (true, 42, a string), return the store's own state object (which the store replaces only on change), or cache the derived value yourself.

  4. 4

    Tearing is when different parts of the screen show different versions of the same data at once, like a torn photo whose two halves don't match. Since React 18, rendering can be concurrent: React may pause a big render to handle a click, then continue. If an outside store changes during that pause, components rendered before the pause read the old value and those rendered after read the new one, and the committed screen disagrees with itself. useSyncExternalStore prevents this: React checks the snapshot again before committing, and if the store changed during the render, it re-renders synchronously (without pausing) so every component sees one value. That's the "Sync" in the name.

  5. 5

    **getServerSnapshot is the optional third argument. During server rendering (Topic 20.1) and hydration** (when React attaches to server-made HTML in the browser, Topic 20.6) there is no navigator or window, and the HTML must match what the server sent. React calls getServerSnapshot on the server and during hydration, then switches to getSnapshot and re-renders if the real value differs. Without it, server rendering a component that uses the hook throws Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering. Return a safe default, the same on server and client (for example, true for online).

  6. 6

    Use it for reading browser APIs (online status, media queries, page visibility, storage), and when writing your own store or wrapping a non-React library. Prefer it over the useState + useEffect subscription from Topic 10.2: that version reads the value once at first render, can miss a change that happens between render and the effect's subscription, and can tear under concurrent rendering. You rarely call it directly in app code: libraries such as Zustand, Redux (react-redux) and TanStack Query use it inside. Updates caused by an external store are always synchronous: they can't be marked as transitions (Topic 16.7), so a slow store-driven render can block typing.

Explain it without notes

01

What do subscribe and getSnapshot each do, and when does React call them?

02

What is tearing, why could it happen in React 18, and how does useSyncExternalStore prevent it?

03

Why must the snapshot be cached, and what happens if it isn't?

04

When do you need getServerSnapshot, and what should it return?

Practice

01

Write useWindowWidth() with useSyncExternalStore, subscribing to resize, with a server snapshot of 1024.

02

Write usePageVisible() that returns true while the tab is visible, using document.visibilityState and the visibilitychange event.

03

Add a useCounterStore() hook to a createStore({ count: 0 }) store and render two <Count /> components plus a button that increments from outside React.

Trade-offs

  • ↔

    useSyncExternalStore guarantees consistency by giving up concurrency for store updates: they always render synchronously. For huge trees driven by a fast-changing store, that can block input; deferring the value or keeping state in React may be smoother.

  • ↔

    A homemade store is tiny and dependency-free, but you must handle selectors, equality and devtools yourself. Past a few stores, a library like Zustand (Topic 15.3) is the same idea with those parts done.

Done when you can

  • I can write subscribe and getSnapshot for a browser API.

  • I keep subscribe stable and return cached or primitive snapshots.

  • I can explain tearing and how this hook prevents it.

  • I add getServerSnapshot whenever a component may render on a server.

  • I can build a small store that several components read with selectors.