External Stores
When to leave React’s built-in state
Section titled “When to leave React’s built-in state”useState, useReducer, and Context handle most apps. You reach for an external store (Zustand, Jotai, Redux Toolkit, XState) when you hit their limits:
- State is read/written by many components in scattered places, and Context’s “re-render every consumer” behavior is too coarse.
- You want fine-grained subscriptions — a component that re-renders only when its slice of the store changes, not on every store update.
- The state has complex logic or lives outside the React tree (a cache, a websocket connection, a state machine) that should exist independently of any component.
An external store keeps state outside React and lets components subscribe to just the parts they use.
// Zustand — the store lives outside React; components select a slice.import { create } from "zustand";
const useCartStore = create((set) => ({ items: [], add: (item) => set((s) => ({ items: [...s.items, item] })),}));
function CartCount() { // Re-renders only when items.length changes — a fine-grained subscription. const count = useCartStore((s) => s.items.length); return <span>{count}</span>;}Zustand’s useCartStore(selector) is built on exactly the primitive this lesson covers: useSyncExternalStore. Every store library (Zustand, Jotai, Redux’s useSelector) uses it under the hood so that reading an external value stays consistent with React’s rendering — you rarely call it directly in app code, but understanding it explains why these libraries behave the way they do.
useSyncExternalStore: subscribing safely
Section titled “useSyncExternalStore: subscribing safely”How does a store safely feed data into React? Historically you’d wire up useEffect + useState, but that has a subtle bug under concurrent rendering: React can render in the background, and an external value can change mid-render, so different components in the same render read different values. That inconsistency is called tearing — part of the UI shows the old value, part shows the new.
React provides useSyncExternalStore to subscribe to any external store without tearing. Store libraries use it under the hood, but you can use it directly:
import { useSyncExternalStore } from "react";
// subscribe: register a callback, return an unsubscribe fn.// getSnapshot: return the current value (must be consistent within a render).function useOnlineStatus() { return useSyncExternalStore( (callback) => { window.addEventListener("online", callback); window.addEventListener("offline", callback); return () => { window.removeEventListener("online", callback); window.removeEventListener("offline", callback); }; }, () => navigator.onLine, // client snapshot () => true // server snapshot (for SSR) );}flowchart LR store["External store (outside React)"] -->|getSnapshot| hook["useSyncExternalStore"] store -->|subscribe/notify| hook hook --> react["React reads one consistent snapshot per render (no tearing)"]
Proof: getServerSnapshot is not optional for SSR
Section titled “Proof: getServerSnapshot is not optional for SSR”The third argument, getServerSnapshot, only matters once you render on the server — but skipping it doesn’t quietly fall back to something reasonable. The reference is direct about the requirement: getServerSnapshot “will be used only during server rendering and during hydration… If you omit this argument, rendering the component on the server will throw an error.” That’s exactly what happens:
import { useSyncExternalStore } from 'react';
function subscribe(callback: () => void) { window.addEventListener('online', callback); window.addEventListener('offline', callback); return () => { window.removeEventListener('online', callback); window.removeEventListener('offline', callback); };}
export function OnlineStatusNoServerSnapshot() { const isOnline = useSyncExternalStore(subscribe, () => navigator.onLine); return <span>{isOnline ? 'online' : 'offline'}</span>;}
export function OnlineStatusWithServerSnapshot() { const isOnline = useSyncExternalStore(subscribe, () => navigator.onLine, () => true); return <span>{isOnline ? 'online' : 'offline'}</span>;}import { renderToString } from 'react-dom/server';import { expect, test } from 'vitest';import { OnlineStatusNoServerSnapshot, OnlineStatusWithServerSnapshot } from '../components/OnlineStatus';
test('omitting getServerSnapshot throws when rendered on the server', () => { expect(() => renderToString(<OnlineStatusNoServerSnapshot />)).toThrow( /Missing getServerSnapshot/, );});
test('providing getServerSnapshot renders successfully on the server', () => { const html = renderToString(<OnlineStatusWithServerSnapshot />); expect(html).toContain('online');});✓ omitting getServerSnapshot throws when rendered on the server✓ providing getServerSnapshot renders successfully on the server
Test Files 1 passed (1) Tests 2 passed (2)The actual thrown message: "Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering." — the reason it must throw rather than silently degrade is hydration: the server has no window/navigator to call your client getSnapshot with, and the client’s first render must match the server’s markup exactly, or React logs a hydration mismatch. getServerSnapshot is what guarantees that first render is identical on both sides.
Subscribe must stay stable
Section titled “Subscribe must stay stable”subscribe has the same identity rule as any dependency: pass a new function every render, and React treats it as a new subscription. The reference states it as a caveat: “If a different subscribe function is passed during a re-render, React will re-subscribe to the store using the newly passed subscribe function. You can prevent this by declaring subscribe outside the component.”
import { useSyncExternalStore, useState } from 'react';
let value = 0;const listeners = new Set<() => void>();export function bump() { value += 1; listeners.forEach((l) => l());}function getSnap() { return value;}
export let subscribeCallCount = 0;export function resetSubscribeCallCount() { subscribeCallCount = 0;}
// ✅ Declared OUTSIDE the component — the exact same function reference every render.export function subscribeStable(cb: () => void) { subscribeCallCount++; listeners.add(cb); return () => listeners.delete(cb);}
export function UnstableSubscribeApp() { const [tick, setTick] = useState(0); // ❌ A brand-new subscribe function every render — React re-subscribes every time. const v = useSyncExternalStore((cb: () => void) => { subscribeCallCount++; listeners.add(cb); return () => listeners.delete(cb); }, getSnap); return ( <> <span data-testid="v">{v}</span> <button data-testid="rerender" onClick={() => setTick((t) => t + 1)}>{tick}</button> </> );}
export function StableSubscribeApp() { const [tick, setTick] = useState(0); const v = useSyncExternalStore(subscribeStable, getSnap); return ( <> <span data-testid="v">{v}</span> <button data-testid="rerender" onClick={() => setTick((t) => t + 1)}>{tick}</button> </> );}import { render, act, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { UnstableSubscribeApp, StableSubscribeApp, resetSubscribeCallCount,} from '../components/SubscribeIdentity';import * as mod from '../components/SubscribeIdentity';
afterEach(cleanup);
test('a new subscribe function every render re-subscribes on every render', () => { resetSubscribeCallCount(); const r = render(<UnstableSubscribeApp />); act(() => { r.getByTestId('rerender').click(); }); act(() => { r.getByTestId('rerender').click(); }); // Mount + 2 re-renders = 3 distinct subscribe() calls. expect(mod.subscribeCallCount).toBe(3);});
test('a stable, module-level subscribe function only subscribes once', () => { resetSubscribeCallCount(); const r = render(<StableSubscribeApp />); act(() => { r.getByTestId('rerender').click(); }); act(() => { r.getByTestId('rerender').click(); }); // Same reference every render — React never re-subscribes. expect(mod.subscribeCallCount).toBe(1);});✓ a new subscribe function every render re-subscribes on every render✓ a stable, module-level subscribe function only subscribes once
Test Files 1 passed (1) Tests 2 passed (2)Every re-subscribe is wasted work (a real addEventListener/removeEventListener pair, or a real store subscription, torn down and rebuilt) — for a DOM event listener that’s cheap, but for a WebSocket or a heavier store client it isn’t. Declare subscribe at module scope, or wrap it in useCallback with stable dependencies.
Tearing, concretely
Section titled “Tearing, concretely”“Tearing” means two components reading the same external value disagree about it within one render — one shows the old value, one shows the new, because the value changed while a concurrent render was already in flight. useSyncExternalStore’s job is to make that impossible. The reference explains the mechanism: “If the store is mutated during a non-blocking Transition update, React will fall back to performing that update as blocking. Specifically, for every Transition update, React will call getSnapshot a second time just before applying changes to the DOM. If it returns a different value than when it was called originally, React will restart the update from scratch… to ensure that every component on screen is reflecting the same version of the store.”
The internal restart-vs-blocking distinction isn’t directly observable from a synchronous test (a test’s act() flushes all pending work before returning either way) — but the guarantee that matters is: no matter when the mutation happens relative to a transition, every consumer of the store ends up agreeing.
import { useSyncExternalStore, startTransition } from 'react';
let count = 0;const listeners = new Set<() => void>();
export function bumpCount(n: number) { count = n; listeners.forEach((l) => l());}
function subscribe(cb: () => void) { listeners.add(cb); return () => listeners.delete(cb);}
function getCount() { return count;}
function Consumer({ testid }: { testid: string }) { const c = useSyncExternalStore(subscribe, getCount); return <span data-testid={testid}>{c}</span>;}
export function TearingApp() { return ( <> {/* Two independent consumers of the SAME store. */} <Consumer testid="a" /> <Consumer testid="b" /> <button onClick={() => { startTransition(() => { bumpCount(5); }); }}>bump</button> </> );}import { render, screen, act, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { TearingApp } from '../components/TearingStore';
afterEach(cleanup);
test('two consumers of the same store never disagree, even when the mutation happens inside a transition', () => { render(<TearingApp />); expect(screen.getByTestId('a').textContent).toBe('0'); expect(screen.getByTestId('b').textContent).toBe('0');
act(() => { screen.getByRole('button').click(); });
// Both consumers settle on the SAME value — never one at "0" and the // other at "5". That agreement is the property useSyncExternalStore exists // to guarantee. expect(screen.getByTestId('a').textContent).toBe('5'); expect(screen.getByTestId('b').textContent).toBe('5');});✓ two consumers of the same store never disagree, even when the mutation happens inside a transition
Test Files 1 passed (1) Tests 1 passed (1)Compare this to the classic pre-useSyncExternalStore pattern (useEffect + local useState copying the external value in): it has no such re-check before commit, so under real concurrent rendering — several components reading the store, a slow render in progress, the store mutated mid-render — two of them can disagree for one frame. useSyncExternalStore is the API React added specifically to close that gap.
The infinite-loop trap: a selector must be cached
Section titled “The infinite-loop trap: a selector must be cached”A “selector” here means whatever getSnapshot returns. If it returns a new object or array literal every single call — even one containing identical values — React can never conclude “nothing changed,” because it compares snapshots with Object.is. The result isn’t just wasted renders; it’s an infinite loop, and React detects and reports it:
import { useSyncExternalStore } from 'react';
type State = { count: number };let state: State = { count: 0 };const listeners = new Set<() => void>();
export function setCount(n: number) { state = { count: n }; listeners.forEach((l) => l());}
function subscribe(cb: () => void) { listeners.add(cb); return () => listeners.delete(cb);}
// ❌ Returns a brand-new object every single call, even when nothing changed.function getSnapshotBad() { return { count: state.count };}
export function BadSelectorConsumer() { const snap = useSyncExternalStore(subscribe, getSnapshotBad); return <span>{snap.count}</span>;}import { render, cleanup } from '@testing-library/react';import { afterEach, expect, test, vi } from 'vitest';import { BadSelectorConsumer } from '../components/BadSelector';
afterEach(cleanup);
test('a getSnapshot that returns a new object every call throws and warns', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<BadSelectorConsumer />)).toThrow(/Maximum update depth exceeded/);
const messages = spy.mock.calls.map((c) => String(c[0])); expect(messages).toContain('The result of getSnapshot should be cached to avoid an infinite loop');
spy.mockRestore();});✓ a getSnapshot that returns a new object every call throws and warns
Test Files 1 passed (1) Tests 1 passed (1)Both messages are real, verbatim React output: the console warning "The result of getSnapshot should be cached to avoid an infinite loop", followed by the render loop hitting React’s own safety limit and throwing "Maximum update depth exceeded...". The fix is to select a primitive (() => state.count), or memoize the derived object/array so the same call with the same underlying state returns the same reference (the standard technique any selector-based store, Zustand included, builds in for you).
Choosing a home for state
Section titled “Choosing a home for state”| Need | Use |
|---|---|
| Used by one component | Local useState / useReducer |
| Shared by a few nearby components | Lift state to a common ancestor |
| Broadly shared, changes rarely | Context (+ useReducer) |
| Scattered access, fine-grained subscriptions, or lives outside React | External store (via useSyncExternalStore) |
The honest summary: reach for a store when you feel the pain, not preemptively. Many teams add Redux on day one and never needed it. Start low on the ladder; climb when the app tells you to.
Mental Model
Section titled “Mental Model”useSyncExternalStore is a checkpoint, not a subscription mechanism you’d normally write by hand. Its whole contract is: “give me a way to ask the store for its current value (getSnapshot), and a way to be told when to ask again (subscribe) — I’ll handle making sure every component that asks gets an answer consistent with every other component asking, even mid-render, even during a transition, even on the very first server-rendered paint (getServerSnapshot).” Store libraries exist to give you a nicer selector API on top; this hook is the consistency guarantee underneath all of them.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
useSyncExternalStore directly | No dependency; full control | You write subscribe/getSnapshot/getServerSnapshot yourself |
| Zustand/Jotai/Redux Toolkit | Ergonomic selectors, devtools, ecosystem | Another dependency and API to learn |
| Selector returning a primitive | Cheap, always comparable, never loops | Must restructure state to expose primitives |
| Selector returning a memoized object | Can shape derived data | One more thing to get wrong (see the infinite-loop trap) |
subscribe at module scope | Subscribes once, ever | Must not close over per-render values it needs — pass those through getSnapshot instead |
Common Misconceptions
Section titled “Common Misconceptions”- “
useSyncExternalStorefetches data.” It doesn’t fetch anything — it subscribes to a store that already exists (in memory, inlocalStorage, in a WebSocket client) and keeps React consistent with it. - “Any object returned from
getSnapshotis fine as long as the values are correct.” The values being correct isn’t enough — the reference must be stable when nothing changed, or React can’t tell “unchanged” from “changed,” and it can spiral into an infinite loop. - “Tearing is a rare edge case, safe to ignore.” It’s specifically a concurrent-rendering hazard — the more your app leans on transitions, Suspense, and background rendering, the more likely two components read an external value at genuinely different points in time without this hook.
- “
getServerSnapshotis optional, like a default parameter.” It’s not optional the moment you render on the server — omitting it throws, exactly as proven above, precisely because a silent fallback would risk a hydration mismatch instead.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting a generated hook or component that calls useSyncExternalStore (directly, or through a small hand-rolled store), check for these:
- Does
getSnapshotconstruct a new object/array/function every call (() => ({ ...state }),() => state.items.filter(...)) instead of returning a cached reference or a primitive? That’s the infinite-loop trap proven above. - Is
subscribedefined inline inside the component body, recreated every render, instead of at module scope or in a stableuseCallback? It still works, but re-subscribes needlessly on every render. - If the component can render on the server (a Next.js/Remix/Astro SSR page, not a client-only SPA), is
getServerSnapshotprovided at all? Its absence only breaks in an SSR environment, which local dev withvite dev/CSR-only setups won’t catch. - Does the code use
useEffect+useStateto mirror an external value instead ofuseSyncExternalStore? That’s the pre-18 pattern this hook replaced specifically because of tearing. - Is a context being used to broadcast a high-frequency external value (a live cursor position, a websocket price feed) to many scattered consumers, when an external store with per-slice selectors would re-render far fewer components?
🐛 Spot the bug
function useCartTotal() {return useSyncExternalStore( cartStore.subscribe, () => cartStore.getItems().reduce((sum, item) => sum + item.price, 0),);}
function useCartSummary() {return useSyncExternalStore( cartStore.subscribe, () => ({ count: cartStore.getItems().length, total: cartStore.getItems().reduce((sum, item) => sum + item.price, 0), }),);}Show the answer
useCartTotal is fine — its `getSnapshot` returns a primitive number, which is always comparable by value. useCartSummary has the bug proven above: its getSnapshot returns a brand-new object literal on every single call, even when the cart hasn’t changed, so React can never conclude “nothing changed” and the component loops until it hits "Maximum update depth exceeded", after first warning "The result of getSnapshot should be cached to avoid an infinite loop". The fix: cache the derived object (recompute only when the underlying items array reference changes, e.g. inside the store itself) so repeated calls against the same state return the exact same reference.