A9 · Part 3 · Advanced
useSyncExternalStore
The correct way to read state that lives outside React.
10On this page
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.
- Between render and the effect running, the value may already be wrong.
- On the server there is no
navigator, so the initial read crashes or has to be guarded. - 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);
| Argument | Must |
|---|---|
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
getSnapshotonce 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."
Opens in the editor: write it, run it, and check it against real tests.
All 54 for this level →Finished reading?
0 of 57 chapters read in this series