Why useSyncExternalStore Is Essential for Safe State Sync in React 18
This article explains the purpose, benefits, and implementation of React 18’s useSyncExternalStore hook, covering its role in synchronizing external state such as browser APIs, preventing UI tearing during concurrent rendering, and providing SSR support with practical code examples like useMediaQuery and useWindowSize.
This article is translated from "useSyncExternalStore First Look"; the original can be read via the link at the bottom.
useSyncExternalStore is a hook introduced in React 18. It is primarily intended for library authors (e.g., @tanstack/query, Jotai, Zustand, Redux) and is listed alongside
useInsertionEffectas a "library hook" in the official docs.
These hooks are provided for library authors to deeply integrate their libraries with the React model and are usually not used in application code.
The React changelog also emphasizes that it serves third‑party libraries.
🔴 In React SSR you must not write code like: <code>if (typeof window !== "undefined") { return localStorage.getItem("xyz") } return fallback; });</code> 🐛 It causes hydration errors. ➡️ The correct approach is to use useSyncExternalStore to avoid hydration problems.
The React docs section "Subscribing to a browser API" notes that
useSyncExternalStoreis added because some browser values may change over time.
Thus, the "external store" is not limited to third‑party libraries; the browser itself (e.g.,
windowproperties) is also an external store that React can subscribe to.
Why not useEffect or useState?
Many wonder why a more complex hook is needed. Using
useState+
useEffectto read browser state has a flaw: the browser‑provided value can change without React noticing, leading to UI tearing.
Browser values may change at any time, but React cannot detect those changes, so you need useSyncExternalStore .
The root cause lies in React 18’s concurrent rendering. React maintains multiple UI versions (current and work‑in‑progress) and can pause rendering to prioritize high‑priority events.
When external state changes between pauses, different renders may read different values, producing inconsistent UI—known as "tearing".
Simple illustration of tearing: Component reads external store (color) and renders blue. During a pause, the store changes to red. After resuming, other components read the new red value. The UI shows both blue and red components, i.e., tearing.
useSyncExternalStoredetects external state changes during rendering and forces a re‑render before committing, guaranteeing consistent UI.
In short, it prevents UI inconsistency when using external data, adds SSR support, and is easy to use.
Example
Two custom hooks are built with
useSyncExternalStore:
useMediaQuery
This hook accesses CSS media queries (e.g.,
prefers-color-scheme).
<code>type MediaQuery = `(${string}:${string})`;
function getSnapshot(query: MediaQuery) {
return window.matchMedia(query).matches;
}
function subscribe(onChange: () => void, query: MediaQuery) {
const mql = window.matchMedia(query);
mql.addEventListener("change", onChange);
return () => {
mql.removeEventListener("change", onChange);
};
}
export function useMediaQuery(query: MediaQuery) {
const subscribeMediaQuery = React.useCallback(
(onChange: () => void) => {
return subscribe(onChange, query);
},
[query]
);
const matches = React.useSyncExternalStore(
subscribeMediaQuery,
() => getSnapshot(query)
);
return matches;
}
</code>Note:
subscribeMediaQuerymust be defined inside the hook so that it captures the current
queryvalue.
Wrapping the subscription in
useCallbackand only recreating it when
querychanges avoids unnecessary performance costs.
useWindowSize
This hook returns the window’s width and height.
<code>function onWindowSizeChange(onChange: () => void) {
window.addEventListener("resize", onChange);
return () => window.removeEventListener("resize", onChange);
}
function getWindowWidthSnapshot() {
return window.innerWidth;
}
function getWindowHeightSnapshot() {
return window.innerHeight;
}
export function useWindowSize({ widthSelector, heightSelector }) {
const windowWidth = useSyncExternalStore(
onWindowSizeChange,
getWindowWidthSnapshot
);
const windowHeight = useSyncExternalStore(
onWindowSizeChange,
getWindowHeightSnapshot
);
return { width: windowWidth, height: windowHeight };
}
</code>An early attempt returned an object from
getSnapshot, which caused a "Too many re-renders" error because the snapshot must be immutable.
Using separate stores for width and height (or memoizing the object) resolves the issue.
Using a selector function to limit re‑renders
By passing a selector to
getSnapshot, you can reduce update frequency. For example, only react to width changes in 100‑pixel steps:
<code>const widthStep = 100; // px
const widthSelector = (w: number) =>
w ? Math.floor(w / widthStep) * widthStep : 1;
function windowWidthSnapshot(selector = (w: number) => w) {
return selector(window.innerWidth);
}
function App() {
const width = useSyncExternalStore(
onWindowSizeChange,
() => windowWidthSnapshot(widthSelector)
);
// ...
}
</code>SSR
The third optional argument,
getServerSnapshot, provides an initial snapshot for server‑side rendering and hydration, preventing rehydration mismatches.
When using
useSyncExternalStoreon the server you must supply
getServerSnapshot, and its output must match the client’s initial snapshot.
If you use the hook on the server, define
getServerSnapshotor an error will be thrown.
Ensure the server snapshot is identical to the client snapshot.
Hooks that read browser‑only values (e.g.,
window) do not work on the server, so you must provide a fallback initial value or render the component only on the client.
Client‑only components
React recommends not rendering such components on the server. You can deliberately throw an error during server rendering, wrap the component in
<Suspense>, and show a fallback UI on the client.
Before the page becomes interactive, you can use the initial snapshot from getServerSnapshot . If the snapshot has no meaning on the server, force the component to render only on the client.
If a component throws on the server, React skips it, finds the nearest
<Suspense>, and renders the fallback HTML. On the client, React retries rendering; if no error occurs, the component appears normally.
Summary
This article introduced what
useSyncExternalStoreis and why it matters, revealing its broader applicability beyond third‑party libraries.
It primarily targets external stores, but the browser itself is also an external store.
It is concurrent‑safe, preventing visual UI inconsistencies.
Unstable subscribe arguments cause re‑subscription on every render.
getSnapshotmust return an immutable value.
The optional
getServerSnapshotis crucial for SSR.
When a server snapshot cannot be provided, render the component only on the client by throwing an error and using
<Suspense>with a fallback.
KooFE Frontend Team
Follow the latest frontend updates
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.