What is useSyncExternalStore in React?
useSyncExternalStore is a React 18 hook that lets components subscribe to external data sources while guaranteeing the same snapshot value across all reads in a single render pass, preventing tearing during concurrent rendering.
Theory
TL;DR
- Think of it as a shared whiteboard: every component in a render sees the exact same value, never a mix of old and new data
- The problem it solves: concurrent React can pause and resume mid-render, so a plain
navigator.onLineread might return different values at different points in the same render - Three arguments:
subscribe(register a listener),getSnapshot(read the current value),getServerSnapshot(optional, for SSR) - Use for browser APIs, third-party stores, and globals outside React. Skip for
useState/useReducer- React already handles those without tearing
Quick example
function useOnlineStatus() {
return useSyncExternalStore(
// subscribe: attach listener, return cleanup
(cb) => {
window.addEventListener("online", cb);
window.addEventListener("offline", cb);
return () => {
window.removeEventListener("online", cb);
window.removeEventListener("offline", cb);
};
},
() => navigator.onLine, // getSnapshot: pure read
() => true // getServerSnapshot: SSR default
);
}React calls getSnapshot once per render and reuses that value for the entire tree. No mutation happening mid-render can change what a component sees.
What tearing actually is
Concurrent React splits work into units of varying priority. While processing a large component tree, the scheduler can pause, let higher-priority work run, then resume. If code reads navigator.onLine directly, one part of the tree might read true (before the network change) and another reads false (after the change) in the same commit. The UI shows both states at once. That is tearing.
useSyncExternalStore fixes this by locking in one atomic value from getSnapshot for the entire render pass.
When to use it
- Browser API changes (online/offline status, media queries,
window.innerWidth) - useuseSyncExternalStore - Third-party stores without built-in React bindings (Zustand internals, Redux DevTools) - use
useSyncExternalStore - Any mutable value that lives outside React and can change without React knowing - use
useSyncExternalStore - Plain React state (
useState,useReducer) - skip this hook, React already handles those without tearing
How it works internally
React calls getSnapshot() once per render pass and caches the result per component. All children reading from the same source get that cached value. When the subscribe callback fires, React schedules a re-render via its scheduler. The callback reference stays stable across renders, so subscriptions do not accumulate. On the server, getServerSnapshot runs instead of getSnapshot because window is not available there.
One thing I noticed in production apps: the most common footgun is returning a new object inside getSnapshot. React compares snapshots by reference, so returning { count: store.count } on every call triggers infinite re-renders. Return primitives or stable references.
Common mistakes
Returning a new object in getSnapshot
// Wrong: new object on every call = infinite re-renders
const getSnapshot = () => ({ count: externalStore.count });
// Right: return a primitive
const getSnapshot = () => externalStore.count;React calls getSnapshot multiple times and checks if the result changed. Different reference equals re-render. Always return primitives or memoize object snapshots.
Mutating state inside getSnapshot
// Wrong: side effect inside snapshot read
const snap = () => { externalStore.value++; return externalStore.value; };
// Right: pure read only
const snap = () => externalStore.value;getSnapshot must be a pure function. Side effects here cause infinite update loops.
Missing cleanup in subscribe
// Wrong: listener never removed
(cb) => { window.addEventListener("resize", cb); }
// Right: return the cleanup function
(cb) => {
window.addEventListener("resize", cb);
return () => window.removeEventListener("resize", cb);
}Without cleanup, each re-render stacks another listener. Memory leak, plus callbacks fire multiple times per event.
Skipping getServerSnapshot
// Wrong: crashes on server where window is undefined
useSyncExternalStore(subscribe, () => window.innerWidth);
// Right: provide a server-safe default
useSyncExternalStore(subscribe, () => window.innerWidth, () => 1024);Omitting the third argument causes hydration mismatch errors. The server renders with no value, the client hydrates with a real one, React warns or throws.
Using it for plain React state
If your data lives in useState or useReducer, this hook adds nothing. React already manages those without tearing. useSyncExternalStore is only for data that lives outside React's control.
Real-world usage
- Zustand (v4+): uses
useSyncExternalStoreinternally for all store subscriptions - Redux Toolkit: powers DevTools time-travel snapshots
- TanStack Query: tracks
windowfocus and blur for background refetch logic - Framer Motion: reads pointer and gesture APIs from the browser
- Any production responsive hook (media queries, viewport dimensions) that needs to avoid layout shifts
Follow-up questions
Q: What is tearing and why does it only matter in concurrent React?
A: Tearing is when two parts of the same render read an external value at different moments and get different results, producing an inconsistent UI. In synchronous React, renders are uninterruptible, so this cannot happen. Concurrent React pauses and resumes work, which makes it possible.
Q: How is this different from useEffect + useState?
A: The useEffect approach lags by one render. You subscribe in an effect, set state, then re-render. With useSyncExternalStore, the read is synchronous and tied to the render itself. No stale render, no intermediate flash.
Q: What happens on SSR if you skip getServerSnapshot?
A: React throws in development and produces a hydration mismatch in production. Always pass a server-safe default that does not rely on browser APIs.
Q: Why must getSnapshot return a consistent value between calls if nothing changed?
A: React calls getSnapshot multiple times during concurrent work to check for mid-render mutations. If it returns different values on repeated calls without the store actually changing, React logs a warning and may force a synchronous re-render.
Q (senior): How does useSyncExternalStore interact with startTransition?
A: startTransition marks state updates as low priority, deferring them. External store updates wrapped with useSyncExternalStore are treated as synchronous by React's scheduler. If a store change fires during a transition, React flushes it synchronously to prevent tearing, even if that interrupts the ongoing transition work.
Examples
Tracking online status
function useOnlineStatus() {
return useSyncExternalStore(
(cb) => {
window.addEventListener("online", cb);
window.addEventListener("offline", cb);
return () => {
window.removeEventListener("online", cb);
window.removeEventListener("offline", cb);
};
},
() => navigator.onLine,
() => true // server: assume online
);
}
function NetworkBanner() {
const isOnline = useOnlineStatus();
if (isOnline) return null;
return <div className="banner">You are offline</div>;
}getSnapshot is a pure read of navigator.onLine. The same value is returned for the entire render pass, regardless of when the network event actually fires.
Responsive layout with media query
function useIsMobile() {
return useSyncExternalStore(
(cb) => {
const mql = window.matchMedia("(max-width: 768px)");
mql.addEventListener("change", cb);
return () => mql.removeEventListener("change", cb);
},
() => window.matchMedia("(max-width: 768px)").matches,
() => false // SSR: assume desktop
);
}
function Sidebar() {
const isMobile = useIsMobile();
return (
<nav className={isMobile ? "sidebar-mobile" : "sidebar-desktop"}>
{/* consistent layout, no flash on resize */}
</nav>
);
}The SSR default of false avoids hydration mismatches on desktop-first layouts. Without getServerSnapshot, the server and client render different class names and React throws a warning.
External store integration (how Zustand works under the hood)
const counterStore = {
count: 0,
listeners: new Set<() => void>(),
increment() {
this.count++;
this.listeners.forEach((l) => l());
},
subscribe(cb: () => void) {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
},
getSnapshot() {
return this.count;
},
};
function Counter() {
const count = useSyncExternalStore(
counterStore.subscribe.bind(counterStore),
counterStore.getSnapshot.bind(counterStore),
() => 0
);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => counterStore.increment()}>+1</button>
</div>
);
}The store mutates directly outside React, but useSyncExternalStore bridges it into the render cycle. Each increment call notifies all listeners, React schedules a re-render, and getSnapshot returns the new count. This is roughly how Zustand v4 implements its React bindings.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.