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 useInsertionEffect as 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 useSyncExternalStore is 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., window properties) 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 + useEffect to 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).
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;
}Note: subscribeMediaQuery must be defined inside the hook so that it captures the current query value.
Wrapping the subscription in useCallback and only recreating it when query changes avoids unnecessary performance costs.
useWindowSize
This hook returns the window’s width and height.
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 };
}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:
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)
);
// ...
}SSR
The third optional argument, getServerSnapshot, provides an initial snapshot for server‑side rendering and hydration, preventing rehydration mismatches.
When using useSyncExternalStore on 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 getServerSnapshot or 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 useSyncExternalStore is 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. getSnapshot must return an immutable value.
The optional getServerSnapshot is 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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
