Subscribing to External Stores Cleanly
Integrating external data sources into React was a minefield of race conditions and inconsistent renders before React 18. useSyncExternalStore is the official solution: a hook that correctly connects external stores to the React rendering cycle and prevents tearing even in Concurrent Mode.
Table of Contents
- 1. What external stores are and why they need special treatment
- 2. The tearing problem in Concurrent Mode
- 3. The API of useSyncExternalStore
- 4. Building your own minimal store
- 5. Subscribing to browser APIs as external stores
- 6. Server-side rendering and getServerSnapshot
- 7. Selectors and performance optimization
- 8. useSyncExternalStore vs. useEffect + useState
- 9. Integration with existing store libraries
- 10. Summary
- 11. FAQ
1. What external stores are and why they need special treatment
An external store is a data source that lives outside the React state system: a Redux store, a Zustand store, a hand-rolled event emitter, a browser API like window.matchMedia or navigator.onLine, or a WebSocket feed. What these sources have in common is that they manage their own state and must notify React of changes, instead of React controlling the state directly.
The problem: React renders components in multiple phases. In Concurrent Mode, React can start a render, pause it and resume it later. If an external store changes its state between two render phases, different parts of the component tree can end up seeing different state snapshots. The result is tearing: the UI shows inconsistent data, part of the page shows the old state, another part shows the new one. useSyncExternalStore is the only correct solution to this problem in React 18+.
2. The tearing problem in Concurrent Mode
Tearing sounds theoretical, but in practice it is a real problem for any application that integrates external state sources with useEffect plus useState. The classic pattern, subscribe in useEffect and call setState in the subscriber, works correctly in the legacy synchronous mode because React performs all renders there synchronously and without interruption. In Concurrent Mode, however, React can interrupt a render traversal to react to more important tasks.
If the external store changes its value during such an interruption, React restarts the render from a certain point. Components that have already rendered saw the old value. Freshly rendered components read the new value. The result is an inconsistent UI within a single commit. useSyncExternalStore prevents this because React checks, after every render pass, whether the snapshot of the external store still matches the one the components read during rendering. If there is a mismatch, React forces a synchronous re-render of the entire affected subtree.
// Correct: useSyncExternalStore prevents tearing
import { useSyncExternalStore } from 'react';
// Minimal external store implementation
function createStore(initialState) {
let state = initialState;
const listeners = new Set();
return {
// React calls this to subscribe, must return unsubscribe function
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
// React calls this synchronously during render, must be stable
getSnapshot() {
return state;
},
// Dispatch triggers all subscribers
setState(newState) {
state = typeof newState === 'function' ? newState(state) : newState;
listeners.forEach(l => l());
},
};
}
const counterStore = createStore({ count: 0 });
function Counter() {
// React subscribes, reads snapshot, and ensures consistency
const { count } = useSyncExternalStore(
counterStore.subscribe,
counterStore.getSnapshot
);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => counterStore.setState(s => ({ count: s.count + 1 }))}>
+1
</button>
</div>
);
}
3. The API of useSyncExternalStore
useSyncExternalStore takes three parameters, two required and one optional. The first parameter is the subscribe function: it receives a listener callback and must return a function that ends the subscription. React calls subscribe on mount and the returned cleanup function on unmount, exactly like useEffect. The second parameter is getSnapshot: a synchronous, side-effect-free function that returns the current value of the store. React calls it on every render.
A critical detail: getSnapshot must return referentially stable values when the store state has not changed. If getSnapshot creates a new object on every call, even with the same values, React treats that as a state change and re-renders. This is the most common performance trap with useSyncExternalStore: snapshots that always return new objects produce endless re-renders. The third, optional parameter is getServerSnapshot, covered in the next section.
4. Building your own minimal store
With useSyncExternalStore, a complete state container can be built in a few lines, without external libraries. The basic pattern consists of three parts: a state store (a variable outside React), a subscriber set (a Set instance for listeners), and a store API (subscribe, getSnapshot, dispatch/setState). This store behaves correctly in Concurrent Mode, supports arbitrary state types and enables the same shared state everywhere in the application, without prop drilling and without context performance issues.
The decisive difference from a naive useState approach: the store lives entirely outside the component tree. State changes from the outside, from a WebSocket handler, a timer or some other non-React context, work correctly because the store notifies its subscribers itself. React reacts to these notifications through useSyncExternalStore correctly and without tearing.
// Generic typed store factory with useSyncExternalStore
import { useSyncExternalStore, useCallback } from 'react';
function createTypedStore<T>(initial: T) {
let state = initial;
const listeners = new Set<() => void>();
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const getSnapshot = () => state;
const setState = (updater: T | ((prev: T) => T)) => {
state = typeof updater === 'function'
? (updater as (prev: T) => T)(state)
: updater;
listeners.forEach(l => l());
};
return { subscribe, getSnapshot, setState };
}
// Create stores as module-level singletons
const userStore = createTypedStore({ name: '', role: 'guest' as const });
const themeStore = createTypedStore<'light' | 'dark'>('light');
// Custom hook for consuming a store with optional selector
function useStore<T, S>(store: ReturnType<typeof createTypedStore<T>>, selector: (s: T) => S): S {
const getSlice = useCallback(() => selector(store.getSnapshot()), [store, selector]);
return useSyncExternalStore(store.subscribe, getSlice);
}
// Usage in components
function UserBadge() {
const name = useStore(userStore, s => s.name);
return <span>{name || 'Guest'}</span>;
}
function ThemeToggle() {
const theme = useSyncExternalStore(themeStore.subscribe, themeStore.getSnapshot);
return (
<button onClick={() => themeStore.setState(t => t === 'light' ? 'dark' : 'light')}>
{theme === 'light' ? 'Dark Mode' : 'Light Mode'}
</button>
);
}
5. Subscribing to browser APIs as external stores
An often overlooked strength of useSyncExternalStore is the clean integration of browser APIs that already come with a subscribe/unsubscribe pattern: window.matchMedia for media query results, navigator.onLine for online status, the visibilitychange event for tab visibility, or the resize event for window dimensions. These APIs deliver state that mutates outside React and must be synchronized into React, exactly the use case for useSyncExternalStore.
The advantage over the classic useEffect pattern: no race condition between reading the initial value on mount and subscribing to changes. With useSyncExternalStore, React reads the snapshot synchronously during render and subscribes at the same time, so no value can be lost between these two operations. For SSR-compatible browser API hooks, getServerSnapshot is also important, providing a safe fallback value for the server, where no browser APIs are available.
6. Server-side rendering and getServerSnapshot
The third parameter of useSyncExternalStore, getServerSnapshot, solves a specific SSR problem. On the server, no browser APIs, no localStorage and no global window state exist. If getSnapshot accesses these sources, it throws errors on the server. getServerSnapshot is the function React calls during server rendering instead of getSnapshot. It must return a stable, deterministic value that is identical on the server and during the first client render, otherwise hydration mismatches occur.
An important constraint: the value that getServerSnapshot returns must be identical during the server render and during the client hydration render. This means browser-specific values such as the current window size or online status must not be read inside getServerSnapshot. Instead, you return a safe default value, for example true for online status or a standard viewport size. Only after hydration does getSnapshot take over and deliver the real browser value.
// Browser API as external store, online status with SSR support
import { useSyncExternalStore } from 'react';
function subscribeToOnline(callback: () => void) {
// Subscribe to browser events
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function getOnlineSnapshot(): boolean {
return navigator.onLine; // browser-only, not called on server
}
function getServerOnlineSnapshot(): boolean {
return true; // safe default for SSR, avoids hydration mismatch
}
function useOnlineStatus(): boolean {
return useSyncExternalStore(
subscribeToOnline,
getOnlineSnapshot,
getServerOnlineSnapshot // third argument: SSR snapshot
);
}
// Media query hook, reactive, SSR-safe
function useMediaQuery(query: string): boolean {
const subscribe = (cb: () => void) => {
const mql = window.matchMedia(query);
mql.addEventListener('change', cb);
return () => mql.removeEventListener('change', cb);
};
return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
() => false // server fallback
);
}
// Usage
function NetworkBadge() {
const isOnline = useOnlineStatus();
const isMobile = useMediaQuery('(max-width: 768px)');
return (
<span style={{ color: isOnline ? 'green' : 'red' }}>
{isOnline ? 'Online' : 'Offline'} {isMobile && '(Mobile)'}
</span>
);
}
7. Selectors and performance optimization
With large external stores, every store change re-renders all components that subscribed to the store via useSyncExternalStore, regardless of whether the part of the store a particular component actually reads has changed. The solution is selectors: getSnapshot does not return the entire store, only the relevant slice. If that slice is unchanged, React detects that through referential equality and skips the re-render.
The problem: if getSnapshot returns a new object via Object.assign or the spread operator, it is referentially different even with identical values, so React re-renders. The solution is memoizing the snapshot: a simple cache comparison inside the selector checks whether the source values have changed and returns the same object reference if not. Libraries such as Zustand and Redux Toolkit solve this internally for their useSyncExternalStore integrations; with hand-rolled stores, you have to implement it yourself.
8. useSyncExternalStore vs. useEffect + useState
The classic pattern for integrating external stores was useEffect + useState: subscribe in the effect, call setState in the subscriber, unsubscribe in the cleanup. This works for simple cases in synchronous render mode, but it has three structural problems: first, there is a short window between the first render and the effect start in which the store state can already have changed, so the first render shows a stale value. Second, the tearing problem occurs in Concurrent Mode. Third, the cleanup of the old subscription and the new subscription can briefly fall out of sync when props or params change.
useSyncExternalStore solves all three problems structurally: there is no window between reading and subscribing, because both happen synchronously in the rendering path. Tearing is prevented by the post-render snapshot check. And subscription changes on changed parameters are handled correctly because React calls subscribe again with the current values. The only advantage of useEffect plus useState is the flexibility for complex asynchronous initialization, which comes at the cost of correctness.
| Property | useEffect + useState | useSyncExternalStore |
|---|---|---|
| Tearing safety | Not guaranteed in Concurrent Mode | Guaranteed |
| Initial state race | Possible (effect delay) | Not possible |
| SSR support | Manual via suppressHydrationWarning | Native via getServerSnapshot |
| Boilerplate | High | Low |
| React integration | External (effect phase) | Native (render phase) |
9. Integration with existing store libraries
All modern React state libraries have rebuilt their internals on top of useSyncExternalStore. Zustand (from v4 onwards) uses it as the core of its React integration. Redux Toolkit uses it in react-redux from v8 onwards. Jotai and Valtio build internally on the same principle. Anyone using one of these libraries already benefits from correct Concurrent Mode semantics without calling useSyncExternalStore themselves.
useSyncExternalStore becomes directly relevant for library authors and for teams that need to integrate proprietary external state sources: WebSocket data feeds, browser extension communication, SharedWorker state or legacy data sources that do not run through React context. In these cases, useSyncExternalStore is the only correct bridge between the external system and React's rendering engine. The entry point is deliberately minimal: anyone who can provide three simple functions (subscribe, getSnapshot, optionally getServerSnapshot) has a fully React-compliant external store.
// WebSocket feed as external store, real-time data without tearing
type PriceData = { symbol: string; price: number; updatedAt: number };
function createPriceFeedStore(wsUrl: string) {
let latestData: PriceData | null = null;
const listeners = new Set<() => void>();
let socket: WebSocket | null = null;
function connect() {
socket = new WebSocket(wsUrl);
socket.onmessage = (event) => {
latestData = JSON.parse(event.data) as PriceData;
listeners.forEach(l => l()); // notify React
};
}
connect(); // connect immediately as module-level singleton
return {
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
getSnapshot(): PriceData | null {
return latestData; // stable reference if unchanged
},
getServerSnapshot(): PriceData | null {
return null; // no WebSocket on server
},
};
}
const priceFeed = createPriceFeedStore('wss://api.example.com/prices');
function LivePriceTicker() {
const data = useSyncExternalStore(
priceFeed.subscribe,
priceFeed.getSnapshot,
priceFeed.getServerSnapshot
);
if (!data) return <span>Connecting...</span>;
return (
<span>
{data.symbol}: {data.price.toFixed(2)} $
</span>
);
}
10. Summary
useSyncExternalStore is the React hook for every case where state lives outside React and needs to be synchronized into the component tree. The three core principles: first, getSnapshot must be pure and synchronous and return referentially stable values. Second, subscribe must return a cleanup function. Third, getServerSnapshot must provide a safe, deterministic server default for SSR-compatible applications.
The key takeaway: the classic useEffect + useState pattern for external stores is not correct in Concurrent Mode, not as a theoretical problem, but as a real tearing risk in every application that uses React 18+ and concurrent features. useSyncExternalStore is the only correct solution, has been stable since React 18 and requires no external library. For browser API hooks, WebSocket feeds, custom state containers and library development, it is the indispensable building block.
useSyncExternalStore, the essentials at a glance
Tearing prevention
React checks after every render whether the snapshot is still current. On mismatch: synchronous re-render. No tearing in Concurrent Mode.
Stable snapshots
getSnapshot must return the same reference when state has not changed. New objects on every call cause endless re-renders.
SSR compatibility
getServerSnapshot provides the server default. Must be identical during the server render and client hydration to prevent hydration mismatches.
Use cases
Browser APIs, WebSocket feeds, custom store containers, library internals, everywhere state lives outside React.