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.onLineor 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.
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.
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.
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.
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.
Try it yourself
- 1
Add a third reader
In the online example, add a component
OfflineBannerthat returnsnullwhen online and a red banner when offline, using the sameuseOnlineStatus(). Predict: does the store need any change? (No: every subscriber is notified.) - 2
Select an object and watch it break
In the cart example, change
CartBadgetoconst { items } = useCart((s) => ({ items: s.items }));. Predict the console, then run. Then fix it by selectings.itemsagain, or by selecting the wholes(the store's own object, which is stable). - 3
Fix the uncached snapshot
In the third example, make
getSnapshotreturnuser, and add a functionrename(name) { user = { ...user, name }; listeners.forEach((l) => l()); }. CallsetTimeout(() => rename("Ravi"), 200)and check the page updates.
Code & diagrams
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
Console
Nothing logged yet.Expected console output
Online / Save
Offline / Reconnecting...
Online / SaveEach 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
Console
Nothing logged yet.Expected console output
CartBadge rendered: 0
CouponLine rendered: (none)
CartBadge rendered: 1
CouponLine rendered: HECTAL10React 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
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.`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
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 }.
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.
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
getSnapshottwice 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
startTransitionto stay non-blocking: if the store changes during a transition render, React restarts synchronously. For expensive derived UI, render fromuseDeferredValue(storeValue)(Topic 16.7) instead. - ▸
If
subscribechanges identity, React resubscribes; ifgetSnapshotchanges identity, React just calls the new one on the next render. Selector-style hooks (useStore(selector)) pass an inlinegetSnapshot, which is fine as long as the result is stable. - ▸
For React 16.8 and 17, the
use-sync-external-store/shimpackage provides the same API.use-sync-external-store/with-selectoradds a selector plus anisEqualcomparator that memoises derived snapshots; react-redux'suseSelectoris built on it.
Remember this
- 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
The hook takes two functions. **
subscribe(callback)** starts listening for changes, callscallbackwhenever the store changes, and returns an unsubscribe function (just like an effect cleanup). **getSnapshot()returns the store's current value, called a snapshot**. React callssubscribeafter the first commit, callsgetSnapshotduring every render, and whencallbackfires it callsgetSnapshotagain: if the result differs from the last one (compared withObject.is), the component re-renders. - 3
Snapshots must be cached.
getSnapshotis called often, and React compares results by identity. If it returns a new object or array every call (return { ...user }oritems.filter(...)), React thinks the store changed every time, re-renders, gets yet another new object, and loops. In development React warnsThe result of getSnapshot should be cached to avoid an infinite loopand then fails withMaximum 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
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.
useSyncExternalStoreprevents 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
**
getServerSnapshotis 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 nonavigatororwindow, and the HTML must match what the server sent. React callsgetServerSnapshoton the server and during hydration, then switches togetSnapshotand re-renders if the real value differs. Without it, server rendering a component that uses the hook throwsMissing 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,truefor online). - 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+useEffectsubscription 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
What do subscribe and getSnapshot each do, and when does React call them?
What is tearing, why could it happen in React 18, and how does useSyncExternalStore prevent it?
Why must the snapshot be cached, and what happens if it isn't?
When do you need getServerSnapshot, and what should it return?
Practice
Write useWindowWidth() with useSyncExternalStore, subscribing to resize, with a server snapshot of 1024.
Write usePageVisible() that returns true while the tab is visible, using document.visibilityState and the visibilitychange event.
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
- ↔
useSyncExternalStoreguarantees 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
subscribeandgetSnapshotfor a browser API.I keep
subscribestable and return cached or primitive snapshots.I can explain tearing and how this hook prevents it.
I add
getServerSnapshotwhenever a component may render on a server.I can build a small store that several components read with selectors.