Skip to content

External Stores

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.

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)"]
useSyncExternalStore keeps React consistent with an outside source

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:

src/components/OnlineStatus.tsx
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>;
}
src/__tests__/ssr-snapshot.test.tsx
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 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.”

src/components/SubscribeIdentity.tsx
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>
</>
);
}
src/__tests__/subscribe-identity.test.tsx
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” 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.

src/components/TearingStore.tsx
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>
</>
);
}
src/__tests__/tearing.test.tsx
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:

src/components/BadSelector.tsx
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>;
}
src/__tests__/bad-selector.test.tsx
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).

NeedUse
Used by one componentLocal useState / useReducer
Shared by a few nearby componentsLift state to a common ancestor
Broadly shared, changes rarelyContext (+ useReducer)
Scattered access, fine-grained subscriptions, or lives outside ReactExternal 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.

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.

ChoiceBenefitCost
useSyncExternalStore directlyNo dependency; full controlYou write subscribe/getSnapshot/getServerSnapshot yourself
Zustand/Jotai/Redux ToolkitErgonomic selectors, devtools, ecosystemAnother dependency and API to learn
Selector returning a primitiveCheap, always comparable, never loopsMust restructure state to expose primitives
Selector returning a memoized objectCan shape derived dataOne more thing to get wrong (see the infinite-loop trap)
subscribe at module scopeSubscribes once, everMust not close over per-render values it needs — pass those through getSnapshot instead
  • “useSyncExternalStore fetches data.” It doesn’t fetch anything — it subscribes to a store that already exists (in memory, in localStorage, in a WebSocket client) and keeps React consistent with it.
  • “Any object returned from getSnapshot is 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.
  • “getServerSnapshot is 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.

Before trusting a generated hook or component that calls useSyncExternalStore (directly, or through a small hand-rolled store), check for these:

  • Does getSnapshot construct 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 subscribe defined inline inside the component body, recreated every render, instead of at module scope or in a stable useCallback? 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 getServerSnapshot provided at all? Its absence only breaks in an SSR environment, which local dev with vite dev/CSR-only setups won’t catch.
  • Does the code use useEffect + useState to mirror an external value instead of useSyncExternalStore? 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.

When is an external store the right choice?
What is "tearing"?
What does useSyncExternalStore do?
What are the two main arguments to useSyncExternalStore?
What happens if you omit getServerSnapshot and render the component on the server?
What happens when this component renders?
function getSnapshotBad() {
  return { count: state.count };
}

function BadSelectorConsumer() {
  const snap = useSyncExternalStore(subscribe, getSnapshotBad);
  return <span>{snap.count}</span>;
}
This subscribe function is defined inline inside the component body. Across a mount plus two re-renders, how many times does it get called?
const v = useSyncExternalStore((cb) => {
  listeners.add(cb);
  return () => listeners.delete(cb);
}, getSnap);