
While standard state hooks like useState and useReducer work seamlessly within React’s standard component tree, managing state from sources outside React’s render lifecycle such as browser APIs, WebSockets, RxJS observables, or state libraries like Redux and Zustand presents a fundamental challenge in Concurrent React.
useSyncExternalStore is React’s solution to concurrent tearing. This deep dive explores how useSyncExternalStore works under the hood, how to build robust selector pipelines with structural sharing, and how to subscribe to complex non-React APIs.
1. The Core Problem: Understanding Tearing
In Concurrent React, rendering is interruptible. React can start rendering a component tree, pause execution to yield to a higher-priority event (like user input), and resume rendering later.
Thread Timeline (With Tearing):
----------------------------------------------------------------------
[Render Component A] ---> (State mutates in external store) ---> [Render Component B]
| |
Reads Val: 1 Reads Val: 2
----------------------------------------------------------------------
Result: A and B display inconsistent UI states for the exact same store!
If an external store mutates while React is paused mid-render, components rendered before the pause will read the old state, while components rendered after the pause will read the updated state. This UI inconsistency is known as tearing.
useSyncExternalStore forces React to treat updates from external stores with synchronous semantics, guaranteeing that every component in the render tree receives the exact same snapshot.
2. API Signature & Execution Flow
const snapshot = useSyncExternalStore<Snapshot>(
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot?: () => Snapshot
);
Key Execution Rules:
subscribe: A function that accepts a singleonStoreChangecallback. When the external store updates, callingonStoreChange()informs React that a mutation occurred. It must return a cleanup function to unsubscribe.getSnapshot: Returns an immutable snapshot of the current state. It must return the exact same reference (===) if the state hasn’t changed.getServerSnapshot: Provides the initial snapshot used during Server-Side Rendering (SSR) and hydration.
3. Advanced Pattern 1: High-Performance Custom Selectors with Structural Sharing
A common mistake when implementing getSnapshot is returning a freshly constructed object or inline array filtering. Doing so breaks reference equality (===), causing React to trigger infinite re-render loops.
To extract slice data safely without extra re-renders, construct derived state using a memoized selector pattern:
import { useSyncExternalStore, useRef, useCallback } from 'react';
type StoreListener = () => void;
// Generic Observable Store
export class Store<T> {
private state: T;
private listeners = new Set<StoreListener>();
constructor(initialState: T) {
this.state = initialState;
}
get = (): T => this.state;
set = (updater: (prev: T) => T): void => {
this.state = updater(this.state);
this.listeners.forEach((listener) => listener());
};
subscribe = (listener: StoreListener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
}
// Custom Hook: Custom Selector with Structural Memoization
export function useStoreSelector<T, SelectorResult>(
store: Store<T>,
selector: (state: T) => SelectorResult
): SelectorResult {
const lastSelectedState = useRef<SelectorResult | null>(null);
const lastState = useRef<T | null>(null);
const getSnapshot = useCallback(() => {
const currentState = store.get();
// 1. Return cached result if raw store reference hasn't changed
if (lastState.current === currentState && lastSelectedState.current !== null) {
return lastSelectedState.current;
}
// 2. Derive new slice
const nextSelectedState = selector(currentState);
// 3. Perform basic shallow equality check on selected slice
if (
lastSelectedState.current !== null &&
areShallowEqual(lastSelectedState.current, nextSelectedState)
) {
return lastSelectedState.current; // Keep old reference to prevent re-render!
}
// 4. Update references
lastState.current = currentState;
lastSelectedState.current = nextSelectedState;
return nextSelectedState;
}, [store, selector]);
return useSyncExternalStore(store.subscribe, getSnapshot);
}
// Utility: Shallow Equality Check
function areShallowEqual<T>(objA: T, objB: T): boolean {
if (Object.is(objA, objB)) return true;
if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) return false;
return keysA.every((key) =>
Object.prototype.hasOwnProperty.call(objB, key) &&
(objA as Record<string, unknown>)[key] === (objB as Record<string, unknown>)[key]
);
}
4. Advanced Pattern 2: Subscribing to Complex Web APIs (e.g., Network Status & Resize Observer)
useSyncExternalStore isn’t restricted to global state management libraries. It is ideal for binding React components directly to native browser APIs that emit imperative events.
Subscribing to Browser Online/Offline Status
import { useSyncExternalStore } from 'react';
function subscribeNetworkStatus(callback: () => void) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function getNetworkSnapshot() {
return navigator.onLine;
}
function getServerNetworkSnapshot() {
return true; // Assume online on server side
}
export function useNetworkStatus(): boolean {
return useSyncExternalStore(
subscribeNetworkStatus,
getNetworkSnapshot,
getServerNetworkSnapshot
);
}
Subscribing to Reactive Media Queries (matchMedia)
import { useSyncExternalStore, useCallback } from 'react';
export function useMediaQuery(query: string): boolean {
const subscribe = useCallback(
(callback: () => void) => {
const matchMedia = window.matchMedia(query);
matchMedia.addEventListener('change', callback);
return () => matchMedia.removeEventListener('change', callback);
},
[query]
);
const getSnapshot = () => window.matchMedia(query).matches;
const getServerSnapshot = () => false;
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
5. Architectural Comparison: When to Use What
| Criteria | useState / useReducer |
useEffect + Local State |
useSyncExternalStore |
|---|---|---|---|
| Primary Target | Internal React state | Event-driven side effects | External non-React stores & Browser APIs |
| Concurrent Safe? | Yes | No (Prone to layout flickers/tearing) | Yes (Guarantees zero tearing) |
| SSR Support | Native | Requires hydration handling | Requires getServerSnapshot |
| Rendering Semantics | Async / Concurrent | Async / Post-paint | Synchronous for store updates |