open atlas
↑ Back to track
React patterns, senior RXP · 07 · 03

useSyncExternalStore

useSyncExternalStore is Reacts blessed primitive for subscribing to any external store safely under concurrent rendering — subscribe + getSnapshot + getServerSnapshot. Stable snapshots avoid render loops; getServerSnapshot avoids SSR crashes.

RXP Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

Some state isn’t yours. navigator.onLine, a matchMedia query, the URL via the History API, a WebSocket client, a third-party store from outside the React world — React doesn’t own any of it, so it can’t know when it changes. The reflex is useEffect + useState: subscribe in an effect, copy the value into state, re-render. It works in the demo and then tears under load.

Under concurrent rendering React can pause, restart, and interleave renders. If your component reads an external value during render and that value changes mid-render, two parts of the tree can commit with two different values — tearing. The useEffect-mirror approach also reads stale on the very first render and breaks under SSR. useSyncExternalStore is the primitive React shipped specifically to make external subscriptions tearing-safe, and it’s what every store library calls under the hood.

Goal

After this lesson you can subscribe a component to any external (non-React) store with useSyncExternalStore by supplying its three arguments — subscribe, getSnapshot, and getServerSnapshot; explain why it’s the correct, tearing-safe bridge under concurrent rendering rather than useEffect + useState; recognise the two failure modes that bite everyone (a getSnapshot that returns a fresh object every call → infinite render loop, and a missing getServerSnapshot → SSR crash); and know when to reach for it directly versus letting a store library do it for you.

1

The signature is three functions: subscribe, getSnapshot, and (for SSR) getServerSnapshot. subscribe(cb) registers a callback React calls whenever the store changes, and returns an unsubscribe function. getSnapshot() returns the current value synchronously. getServerSnapshot() returns the value to use during server rendering and the initial client hydration, where browser APIs don’t exist.

"use client";
import { useSyncExternalStore } from "react";

function useOnlineStatus(): boolean {
  return useSyncExternalStore(
    // subscribe: wire store -> React, return cleanup
    (onStoreChange) => {
      window.addEventListener("online", onStoreChange);
      window.addEventListener("offline", onStoreChange);
      return () => {
        window.removeEventListener("online", onStoreChange);
        window.removeEventListener("offline", onStoreChange);
      };
    },
    () => navigator.onLine,        // getSnapshot (client)
    () => true,                    // getServerSnapshot (no navigator on the server)
  );
}

React subscribes once, reads the snapshot on every render, and re-renders the component when onStoreChange fires. No effect, no mirrored useState, no stale first render.

2

Why not useEffect + useState? Because that mirror tears under concurrent rendering and is stale on first paint. The effect runs after the first commit, so the first render shows whatever default you seeded, not the real value. Worse, because the value lives in your own state copy, a concurrent render that started before an update can commit an old value while another part of the tree commits the new one — the same store read two different things. useSyncExternalStore reads the snapshot synchronously during render and forces a consistent, non-torn read across the whole commit.

// ❌ the reflex: subscribe-in-effect, mirror into state
function useOnlineStatusBad() {
  const [online, setOnline] = useState(navigator.onLine); // crashes on the server too
  useEffect(() => {
    const on = () => setOnline(navigator.onLine);
    window.addEventListener("online", on);
    window.addEventListener("offline", on);
    return () => {
      window.removeEventListener("online", on);
      window.removeEventListener("offline", on);
    };
  }, []);
  return online; // stale before the effect runs; can tear under concurrent renders
}

This is also why React’s own docs list it under “you might not need an effect”: subscribing to an external store is exactly the case where the effect-mirror is the wrong tool and this hook is the right one.

3

getSnapshot MUST return a stable reference for unchanged data — a fresh object every call is an infinite render loop. After each render React calls getSnapshot and compares the result to the last one with Object.is. If it’s a new value, React assumes the store changed and re-renders — which calls getSnapshot again — which returns yet another new object — forever. The fix is to return the same reference until the underlying data actually changes (cache it in the store), or select a primitive.

// ❌ new array identity every call -> Object.is is always false -> loop
const todos = useSyncExternalStore(store.subscribe, () => store.todos.filter(t => !t.done));

// ✅ return a cached reference; recompute only when the store mutates
const todos = useSyncExternalStore(store.subscribe, store.getVisibleTodos);
// where getVisibleTodos memoises and only produces a new array when todos change

// ✅ or read a primitive, which is compared by value
const count = useSyncExternalStore(store.subscribe, () => store.todos.length);

If you genuinely need a derived object per render, derive it outside the hook with useMemo from a stable snapshot — never inside getSnapshot. (useSyncExternalStoreWithSelector from use-sync-external-store/shim/with-selector exists for exactly the select-and-compare case.)

4

Omit getServerSnapshot in any SSR/RSC-client app and you crash the server render. During server rendering and the first hydration pass there is no window or navigator, so getSnapshot throws (navigator is not defined) — or, if it doesn’t throw, the server HTML and the client’s first render disagree and you get a hydration mismatch. getServerSnapshot supplies a safe, deterministic value for the server and hydration; React then re-reads getSnapshot after mount and updates if the real value differs.

return useSyncExternalStore(
  subscribe,
  () => navigator.onLine,  // client snapshot — browser only
  () => true,              // server snapshot — assume online during SSR/hydration
);

In Next.js App Router this hook only runs in a "use client" component, but client components are still server-rendered for the initial HTML, so the third argument is not optional in that world — it’s the difference between a clean hydration and a runtime crash on first request.

Worked example

Bridge a tiny custom store, then read it from two components without tearing. Say you have a non-React store — a few subscribers and a value. This is the shape every store library wraps; doing it by hand once makes the abstraction obvious.

// store.ts — plain module, zero React
type Listener = () => void;
function createCounterStore() {
  let count = 0;
  const listeners = new Set<Listener>();
  return {
    increment() {
      count += 1;
      listeners.forEach((l) => l()); // notify React
    },
    subscribe(listener: Listener) {
      listeners.add(listener);
      return () => listeners.delete(listener); // React calls this to clean up
    },
    getSnapshot() {
      return count; // primitive -> compared by value, always stable
    },
  };
}
export const counterStore = createCounterStore();

Now the hook, and two consumers that are guaranteed to read the same value in a single commit:

"use client";
import { useSyncExternalStore } from "react";
import { counterStore } from "./store";

function useCounter() {
  return useSyncExternalStore(
    counterStore.subscribe,
    counterStore.getSnapshot,
    () => 0, // getServerSnapshot: deterministic initial value for SSR/hydration
  );
}

function Display() {
  const count = useCounter();
  return <span>Count: {count}</span>;
}
function Controls() {
  const count = useCounter(); // same store, same snapshot — cannot tear
  return <button onClick={() => counterStore.increment()}>+ ({count})</button>;
}

Contrast the before: an useEffect/useState mirror per component would each hold their own copy of count, and under a concurrent render one could update before the other — two values on screen for the same store. With useSyncExternalStore, both useCounter calls read one synchronous snapshot during the same commit, so Display and Controls always agree. We passed a primitive snapshot, so there’s no identity trap; the getServerSnapshot returns 0 so SSR and hydration match.

Why this works

Why does this exist when libraries like Zustand, Jotai, Redux, and Valtio already manage external state? Because they call useSyncExternalStore internally — it is the official, version-stable contract between non-React state and React’s renderer, the only API that can guarantee a non-torn read under concurrent features. You reach for it directly when you’re integrating something that isn’t a state library at all: a browser API (onLine, matchMedia, localStorage, document.visibilityState), a non-React event emitter, a game loop, a WebSocket client, or your own 30-line store. If a library already wraps your store, use the library; this hook is for the bare metal underneath it.

Common mistake

The single most common bug is defining subscribe or getSnapshot inline in the component body. A new subscribe function each render makes React tear down and re-add the subscription on every render; a getSnapshot that closes over and returns a freshly-built object loops forever. Hoist both functions to module scope or useCallback/useMemo them, and make getSnapshot return a cached reference (or a primitive). If you need a selected/derived slice, use useSyncExternalStoreWithSelector rather than computing inside getSnapshot.

Check yourself
Quiz

A component using useSyncExternalStore freezes the tab — React is re-rendering it in a tight loop. The getSnapshot passed is () => store.items.filter(i => i.active). What is the cause and fix?

Recap

useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?) is React’s blessed primitive for subscribing a component to any external, non-React store safely under concurrent rendering. subscribe(cb) wires the store’s change events to React and returns a cleanup; getSnapshot() reads the current value synchronously during render so the read is tearing-safe — every part of a single commit sees the same value, which the useEffect + useState mirror cannot guarantee (and that mirror is also stale on first paint and SSR-unsafe). Two failure modes define correct use: getSnapshot must return a stable reference for unchanged data or you get an infinite render loop, and getServerSnapshot must be supplied in any SSR/RSC-client app or the server render crashes / mismatches on hydration. Reach for it directly when bridging a browser API, an event emitter, or your own tiny store; let a store library reach for it when you’re already using one — because every one of them calls this exact hook underneath.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.