React useSyncExternalStore in 2026: The Hook Every State Library Uses and Why You Should Understand It
Understand the hook that powers Zustand, Jotai, and Redux. Learn why React 18's concurrent rendering demands useSyncExternalStore and how to build type-safe external stores that never tear.
Most React state management confusion stems from teams treating external stores as if they're native React state. The assumption breaks in React 18's concurrent rendering model, where a component can read from a store twice during a single render and receive different values. This inconsistency—called tearing—corrupts UI state in ways that are expensive to debug and embarrassing to ship.
The pattern that eliminates tearing is useSyncExternalStore, the hook that every production state library now uses under the hood. Developers dismiss it as "library internals" without realizing it's the foundation that makes Zustand, Jotai, and Redux work correctly in concurrent mode. When you understand this hook, you understand why your state library behaves the way it does—and when you need to build a custom store, you know exactly how to integrate it safely.
flowchart LR
A("Component reads store") --> B("Concurrent render interrupts")
B --> C("Component re-reads store")
C --> D("store value changed between reads")
D --> E("UI renders with mixed old and new state")
style D stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style E stroke:#ef4444,fill:#450a0a,color:#fca5a5
React 18 introduced time-slicing and transitions that allow renders to pause and resume. A store outside React's control can change during that pause. Without useSyncExternalStore, the resumed render sees new data while sibling components still reference old snapshots. The UI enters an inconsistent state where a product list shows five items but the cart count displays four.
flowchart LR
A("Component reads store") --> B("useSyncExternalStore subscribes")
B --> C("Store changes trigger synchronous re-render")
C --> D("All components read same snapshot")
D --> E("UI stays consistent across concurrent boundaries")
style C stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
useSyncExternalStore solves this by forcing React to commit any in-progress render when the external store changes. The guarantee is simple: every component in a render tree sees the same snapshot value, even when renders interleave or suspend. This synchronization is what "sync" means in the hook's name—it's not about synchronous code execution, it's about snapshot consistency.
Key Takeaways
useSyncExternalStoreprevents tearing by ensuring all components in a concurrent render read the same snapshot value from an external store.- The hook requires three pieces: a stable
subscribefunction that registers a listener, agetSnapshotfunction that returns immutable data, and optionally agetServerSnapshotfor SSR hydration. - Every major state library (Zustand, Jotai, Redux Toolkit) now uses
useSyncExternalStoreinternally—understanding it reveals why their APIs enforce certain patterns like immutable updates. - Building a custom store with this hook takes fewer than 50 lines but demands strict adherence to immutability and subscription stability to avoid infinite loops.
- Choose
useSyncExternalStorefor any data source outside React's control (browser APIs, WebSockets, shared workers); use Context for component-tree-scoped state that doesn't need external sync.
The Hook Signature: subscribe, getSnapshot, and getServerSnapshot
useSyncExternalStore accepts three arguments, and their interaction determines whether the integration succeeds or fails. The first argument is subscribe, a function that takes a callback and registers it with the external store. When the store changes, it must invoke all registered callbacks. The second argument is getSnapshot, which returns the current store value. React calls this function during render and compares the returned reference to detect changes. The optional third argument is getServerSnapshot, which provides the initial value during server-side rendering when the external store doesn't exist yet.
flowchart TD
A("useSyncExternalStore called") --> B("React invokes subscribe with callback")
B --> C("subscribe registers callback with store")
C --> D("Store changes")
D --> E("Store invokes all registered callbacks")
E --> F("React calls getSnapshot")
F --> G("React compares new snapshot to previous")
G --> H{"Reference changed?"}
H -->|Yes| I("React schedules synchronous re-render")
H -->|No| J("No re-render")
I --> K("All components read consistent snapshot")
style F stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style I stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The contract between these functions is rigid. The subscribe function must return an unsubscribe function that removes the callback when the component unmounts. React expects this cleanup to prevent memory leaks when components re-render or unmount. The failure mode here is subtle: if subscribe returns undefined or a non-function, React silently skips cleanup and the callback continues firing after the component is gone, updating state that no longer exists.
The getSnapshot function must return the same reference for equal values. React uses Object.is comparison to determine if a re-render is necessary. Returning a new object on every call—even if the contents are identical—triggers infinite render loops. This is why store implementations typically cache snapshots and only create new references when the underlying data actually changes. The discipline required here is stricter than useMemo or useCallback because React cannot fix violations for you.
The getServerSnapshot argument addresses the timing mismatch between server and client. On the server, external stores like localStorage or WebSocket connections don't exist. React needs a value to render the initial HTML. When the client hydrates, it must use the same initial value to match the server-rendered markup, then switch to the live store. Omitting getServerSnapshot when targeting SSR causes hydration mismatches that manifest as content flashes or suppressed event handlers.
Building a Simple External Store: Browser Storage Example
The clearest way to understand useSyncExternalStore is to build a custom hook that wraps browser storage. The requirement is simple: when localStorage changes in one tab, all subscribed components across all tabs must re-render with the new value. This cross-tab synchronization is exactly the kind of external data source that Context cannot handle and where custom event listeners fail without careful subscription management.
function createStorageStore<T>(key: string, initialValue: T) {
let currentValue = initialValue;
const listeners = new Set<() => void>();
// Load initial value from localStorage
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(key);
if (stored !== null) {
try {
currentValue = JSON.parse(stored);
} catch {
// If parse fails, use initialValue
}
}
}
const subscribe = (callback: () => void) => {
listeners.add(callback);
// Listen to storage events from other tabs
const handleStorage = (e: StorageEvent) => {
if (e.key === key) {
const newValue = e.newValue ? JSON.parse(e.newValue) : initialValue;
currentValue = newValue;
listeners.forEach(listener => listener());
}
};
window.addEventListener('storage', handleStorage);
return () => {
listeners.delete(callback);
window.removeEventListener('storage', handleStorage);
};
};
const getSnapshot = () => currentValue;
const getServerSnapshot = () => initialValue;
const setState = (nextValue: T | ((prev: T) => T)) => {
const newValue = typeof nextValue === 'function'
? (nextValue as (prev: T) => T)(currentValue)
: nextValue;
currentValue = newValue;
localStorage.setItem(key, JSON.stringify(newValue));
listeners.forEach(listener => listener());
};
return { subscribe, getSnapshot, getServerSnapshot, setState };
}
function useLocalStorage<T>(key: string, initialValue: T) {
const store = React.useMemo(
() => createStorageStore(key, initialValue),
[key]
);
const value = React.useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getServerSnapshot
);
return [value, store.setState] as const;
}
// Usage
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Current theme: {theme}
</button>
);
}The implementation maintains a listeners Set to track subscribed components. When setState is called, it updates both the in-memory currentValue and localStorage, then notifies all listeners. This dual update is critical: updating only localStorage would miss components in the same tab, while updating only memory would miss cross-tab synchronization.
The storage event listener handles changes from other tabs. Browser storage events only fire in tabs that did not trigger the change. When another tab calls setTheme, the event fires in this tab, updating currentValue and notifying local listeners. Without this listener, the hook would only work within a single tab—a common mistake when developers test in one browser window.
The useMemo call ensures createStorageStore runs once per unique key. Without it, every render would create a new store instance with a new subscribe function, and React would treat that as a subscription change, triggering unsubscribe-then-resubscribe on every render. This creates a memory leak where old listeners accumulate because the cleanup function references a stale listeners Set.
How Zustand, Jotai, and Redux Use useSyncExternalStore Under the Hood
State libraries adopted useSyncExternalStore to eliminate tearing without forcing developers to understand the hook directly. The abstraction works because these libraries control the store implementation and can guarantee the subscription and snapshot contracts. When developers call useStore or useAtom, they're indirectly invoking useSyncExternalStore with library-managed functions.
flowchart LR
subgraph Zustand["Zustand store.subscribe"]
A1("create((set) => state)")
A2("Internal listeners Set")
A3("useStore calls useSyncExternalStore")
end
subgraph Jotai["Jotai atom.subscribe"]
B1("atom with read/write")
B2("Atom value cache")
B3("useAtom calls useSyncExternalStore")
end
subgraph Redux["Redux store.subscribe"]
C1("configureStore with reducers")
C2("Middleware pipeline")
C3("useSelector calls useSyncExternalStore")
end
A3 --> D("Components read consistent snapshots")
B3 --> D
C3 --> D
D --> E("Concurrent renders never tear")
style D stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Zustand exposes a subscribe method on the store object that accepts a listener function. Internally, it maintains a Set of listeners identical to the storage example above. When developers call set to update state, Zustand updates the internal store object, then invokes every listener. The useStore hook wraps this with useSyncExternalStore, passing the store's subscribe method directly and a getSnapshot function that returns the current state reference.
Jotai takes a different approach because its atoms are independent units rather than a single store. Each atom has its own subscription mechanism, but the library maintains a global WeakMap that tracks which atoms are mounted and which components subscribe to each. When an atom's value changes, Jotai looks up all subscribed components in the WeakMap and notifies them. The useAtom hook calls useSyncExternalStore with an atom-specific subscribe function that registers the component in this WeakMap.
Redux Toolkit's useSelector hook migrated from a custom subscription system to useSyncExternalStore in version 8. The store's subscribe method registers listeners that fire on every action dispatch. The getSnapshot function runs the selector against the current state and returns the result. Redux compares the selector output with Object.is, so selectors that return new objects on every call cause the same infinite loop problem. This is why Redux documentation emphasizes memoized selectors—it's a requirement of useSyncExternalStore, not a Redux-specific optimization.
The common pattern across all three libraries is that they handle subscription stability and snapshot immutability internally, so developers never write a subscribe function by hand. This abstraction is valuable, but it also means teams adopt these libraries without understanding why certain patterns—like immutable updates in Zustand or memoized selectors in Redux—are mandatory rather than suggested.
Implementing a Type-Safe Mini State Manager in 40 Lines
Understanding the hook's mechanics makes building a custom store straightforward when the requirements don't fit existing libraries. The goal is a global store with actions, TypeScript safety, and automatic re-renders—essentially Zustand's core features in minimal code.
type Listener = () => void;
type SetState<T> = (partial: Partial<T> | ((state: T) => Partial<T>)) => void;
interface StoreApi<T> {
getState: () => T;
setState: SetState<T>;
subscribe: (listener: Listener) => () => void;
}
function createStore<T extends Record<string, unknown>>(
initialState: T
): StoreApi<T> {
let state = initialState;
const listeners = new Set<Listener>();
const getState = () => state;
const setState: SetState<T> = (partial) => {
const nextState = typeof partial === 'function'
? partial(state)
: partial;
state = { ...state, ...nextState };
listeners.forEach(listener => listener());
};
const subscribe = (listener: Listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
return { getState, setState, subscribe };
}
function createUseStore<T extends Record<string, unknown>>(
store: StoreApi<T>
) {
return function useStore(): T;
return function useStore<U>(selector: (state: T) => U): U;
return function useStore<U>(selector?: (state: T) => U) {
const selectedState = React.useSyncExternalStore(
store.subscribe,
() => selector ? selector(store.getState()) : store.getState(),
() => selector ? selector(store.getState()) : store.getState()
);
return selectedState as U extends undefined ? T : U;
};
}
// Usage with actions
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
const counterStore = createStore<CounterState>({
count: 0,
increment: () => {},
decrement: () => {},
reset: () => {},
});
// Bind actions after store creation
counterStore.setState({
increment: () => counterStore.setState(s => ({ count: s.count + 1 })),
decrement: () => counterStore.setState(s => ({ count: s.count - 1 })),
reset: () => counterStore.setState({ count: 0 }),
});
const useCounter = createUseStore(counterStore);
function Counter() {
const { count, increment, decrement, reset } = useCounter();
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
);
}The createStore function establishes the core pattern: a closure that encapsulates state and listeners, exposing three methods that implement the useSyncExternalStore contract. The setState implementation merges partial updates with the current state using the spread operator, ensuring a new reference even when only one property changes. This reference change is what triggers React's comparison in useSyncExternalStore.
The createUseStore wrapper adds TypeScript overloads that support both full state selection and custom selectors. The hook calls useSyncExternalStore with the store's subscribe method and a snapshot function that applies the selector. The selector runs on every call to getSnapshot, which happens during render and whenever the store changes. Expensive selectors here would hurt performance, but for most applications the cost is negligible compared to component render time.
The action binding pattern addresses a common confusion with this architecture. Actions need access to setState, but setState is only available after createStore returns. The solution is to define action placeholders in the initial state, then bind the real implementations after store creation. This two-step initialization feels awkward at first but eliminates circular dependencies and keeps the store creation function pure.
Common Mistakes: Snapshot Mutation, Unstable subscribe, and SSR Hydration
The three failure modes that break useSyncExternalStore integrations all stem from violating the hook's contracts. These bugs are invisible in development and only surface in production when concurrent rendering or server hydration exposes the timing assumptions.
flowchart LR
A("Component mounts") --> B{"subscribe function stable?"}
B -->|No| C("React unsubscribes and resubscribes on every render")
C --> D("Old listeners accumulate")
D --> E("Memory leak and duplicate updates")
B -->|Yes| F{"getSnapshot returns same reference?"}
F -->|No| G("React detects change on every call")
G --> H("Infinite render loop")
F -->|Yes| I{"getServerSnapshot provided for SSR?"}
I -->|No| J("Hydration mismatch")
J --> K("React suppresses event handlers")
I -->|Yes| L("Store integrates safely")
style C stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style E stroke:#ef4444,fill:#450a0a,color:#fca5a5
style G stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style H stroke:#ef4444,fill:#450a0a,color:#fca5a5
style J stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style K stroke:#ef4444,fill:#450a0a,color:#fca5a5
style L stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Snapshot mutation happens when developers modify the returned object instead of creating a new reference. A store that maintains an array and calls array.push() violates immutability because the array reference stays the same. React's Object.is comparison sees no change, so components don't re-render even though the data changed. The fix is always to replace the array with a new one: [...array, newItem]. This requirement mirrors Redux's reducer rules and exists for the same reason—reference equality is the only performant way to detect changes.
Unstable subscribe functions occur when the subscribe callback is defined inline without useCallback or when it closes over changing variables. React treats function identity changes as subscription changes and runs the cleanup logic. If cleanup doesn't properly remove the old listener, the Set or array of listeners grows without bound. The symptom is components re-rendering multiple times per state change—once per accumulated listener. The fix is to ensure subscribe is defined once at store creation time and returns a stable function reference.
SSR hydration mismatches manifest when getServerSnapshot is omitted or returns a different value than the initial client-side call to getSnapshot. The server renders the component with one value, React hydrates with another, and the mismatch causes React to assume the HTML is invalid. In production mode with selective hydration, React silently suppresses event handlers on the mismatched nodes, leading to buttons that don't respond to clicks. The fix is to provide a getServerSnapshot that matches the server environment—often just returning the initialValue from store creation.
When to Use useSyncExternalStore vs Context vs External Libraries
The decision point for useSyncExternalStore is whether the data source lives outside React's render lifecycle. Browser APIs, WebSocket connections, third-party libraries with their own state—these are external stores that change independently of component renders. Context and useState are sufficient when state exists only within the component tree and React controls when it changes.
flowchart LR
A("State requirement") --> B{"Data source outside React?"}
B -->|Yes| C{"Need to share across component trees?"}
C -->|Yes| D("useSyncExternalStore with global store")
C -->|No| E{"Need concurrent-safe subscriptions?"}
E -->|Yes| D
E -->|No| F("Custom hook with useState")
B -->|No| G{"State scoped to one component?"}
G -->|Yes| H("useState")
G -->|No| I{"State scoped to subtree?"}
I -->|Yes| J("Context with useMemo provider value")
I -->|No| K("External library like Zustand")
style D stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style H stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style J stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style K stroke:#7c9cf0,fill:#142544,color:#eaf2ff
Context is the right choice for theme, locale, or authenticated user data that needs to flow down the tree but doesn't need to synchronize with external changes. The key consideration is whether the state could change while a component is suspended or rendering concurrently. If not—if state changes only happen through user actions that trigger synchronous React updates—Context handles it. The performance concern with Context isn't about the hook itself but about how often the provider value changes and re-renders consumers.
External libraries like Zustand or Jotai become valuable when multiple parts of the application need to share state that isn't naturally parent-child related. A shopping cart that three different routes access is easier to model as a global store than to lift state to a shared ancestor and prop-drill through unrelated components. These libraries use useSyncExternalStore internally, so choosing them isn't avoiding the hook—it's choosing a battle-tested implementation instead of writing one from scratch.
The line between "build your own" and "use a library" depends on complexity, not just features. A global store with two or three slices of state and a handful of actions fits comfortably in 50 lines of custom code. A store with async actions, persistence, devtools integration, and complex selector patterns justifies the dependency on a library. The cost of the library is maintenance and bundle size; the cost of custom code is testing and documentation.
Frequently Asked Questions
Why does my component re-render infinitely when using useSyncExternalStore?
The most common cause is a getSnapshot function that returns a new object reference on every call, violating React's Object.is comparison. Ensure getSnapshot caches its result and only returns a new reference when the underlying data actually changes, typically by maintaining a mutable state variable that updates only on subscription callbacks.
Can I use useSyncExternalStore with async data sources like fetch or WebSocket?
Yes, but the getSnapshot function must always return a synchronous value representing the current state—"loading", "error", or the resolved data. The subscription logic handles async events (WebSocket messages, fetch completion) and updates the cached state, which getSnapshot then returns. Never make getSnapshot itself async or return a Promise.
Do I need getServerSnapshot if my app doesn't use server-side rendering?
No, the third argument is optional and only necessary when rendering on the server. For client-only apps, omit it entirely. React only calls getServerSnapshot during server rendering to generate initial HTML, so providing it for a client-only app has no effect but doesn't cause errors.
How do I prevent memory leaks with useSyncExternalStore subscriptions?
The subscribe function must return an unsubscribe callback that removes the listener from the store's internal Set or array. When React unmounts the component or the subscription changes, it calls this cleanup function. Forgetting to return a cleanup function or returning a non-function value prevents React from cleaning up, leaving stale listeners that continue firing after the component unmounts.
Why do libraries like Zustand still recommend memoized selectors if useSyncExternalStore handles subscriptions?
useSyncExternalStore prevents tearing but doesn't prevent unnecessary renders. A selector that returns a new object on every call (state => ({ count: state.count })) creates a new reference even when count hasn't changed, triggering re-renders. Memoization ensures the selector returns the same reference for equal values, which React's Object.is comparison then recognizes as unchanged, skipping the render.
useSyncExternalStore in 2026: The Foundation of Modern React State
The hook that developers dismissed as "advanced" or "library internals" is now unavoidable for anyone working with React 18+ in production. Understanding useSyncExternalStore is understanding why state libraries enforce immutability, why selectors need memoization, and why Context doesn't solve every sharing problem. These aren't arbitrary rules—they're consequences of the concurrent rendering model and the guarantees this hook provides.
The practical impact is that teams building custom integrations—browser storage, WebSocket state, third-party SDK connections—now have a clear pattern instead of fragile workarounds. The days of "just use an event listener and useState" are over. That approach breaks subtly under concurrent rendering, and fixing it after the fact is expensive. Building on useSyncExternalStore from the start means the integration scales from prototype to production without rewrites.
For library authors, this hook is non-negotiable. Any state management library that doesn't use useSyncExternalStore internally will tear under concurrent rendering, and users will report inconsistent UI states that are impossible to reproduce reliably. The migration from custom subscription systems to this hook is why Zustand, Jotai, and Redux all had major version bumps in the React 18 era. The API surface stayed similar, but the internal implementation had to change to guarantee safety.
That covers the essential patterns for useSyncExternalStore. Apply these in production and the difference will be immediate—components that stay consistent under suspense, state that synchronizes across tabs, and integrations that don't break when React introduces new concurrent features. The hook isn't just about preventing bugs; it's about building state architecture that doesn't need defensive workarounds.