Frontend Development 13 min read

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.

KooFE Frontend Team
KooFE Frontend Team
KooFE Frontend Team
Why useSyncExternalStore Is Essential for Safe State Sync in React 18
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.
useSyncExternalStore

detects 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:

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.

<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

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

&lt;Suspense&gt;

, 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

&lt;Suspense&gt;

, 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

&lt;Suspense&gt;

with a fallback.

frontendReactSSRHooksConcurrent RenderinguseSyncExternalStore
KooFE Frontend Team
Written by

KooFE Frontend Team

Follow the latest frontend updates

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.