Understanding useSyncExternalStore: Eliminating Tearing in Concurrent React
Managing state inside React's built-in primitives (useState, useReducer, Context) guarantees that React remains in full control of scheduling and rendering. However, real-world frontend applications inevitably depend on external state sources: browser APIs (like navigator.onLine or media queries), global state libraries (like Zustand or Redux), WebSocket subscriptions, or custom event emitters.
In legacy synchronous React, subscribing to these stores using a combination of useState and useEffect was common practice. But with the advent of Concurrent React (React documentation) and its modern continuation in React 19, that legacy pattern introduces a subtle, dangerous bug known as tearing.
To resolve this architectural challenge, React provides useSyncExternalStore (React documentation). In this deep dive, we explore what visual tearing is, why useEffect fails in concurrent rendering, how the useSyncExternalStore contract operates, and how to safely bind external stores.
What is Visual Tearing?
In traditional React, rendering was an uninterruptible, synchronous block of work. Once rendering began, no other JavaScript could execute until the DOM was committed.
In Concurrent React, rendering is concurrent and interruptible. React can start rendering a component tree, yield execution back to the browser event loop to process user input or animations, and resume rendering later.
How Tearing Occurs
Imagine a dashboard with two components, Component A and Component B, that both read a shared counter from an external non-React store:
- React begins a render pass. Component A reads the store value:
count = 1. - React yields the thread to keep the browser responsive.
- While the thread is paused, an external WebSocket event arrives and mutates the store:
count = 2. - React resumes rendering and evaluates Component B. Component B reads the store value:
count = 2. - The render commits to the screen.
The user now sees Component A displaying 1 while Component B displays 2 for the exact same data! This visual inconsistency is called tearing.
Why useEffect + useState Cannot Solve Tearing
Many developers attempt to write custom subscription hooks like this:
// ❌ FLAWED CONCURRENT PATTERN:
function useLegacyStore(store) {
const [state, setState] = useState(store.getState());
useEffect(() => {
const unsubscribe = store.subscribe(() => {
setState(store.getState());
});
return unsubscribe;
}, [store]);
return state;
}
This pattern suffers from two critical flaws:
- Passive Execution:
useEffectruns after the browser paints the frame. If the external store changes between the initial render and the effect firing, the component renders stale data before flashing to the updated state. - Cannot Prevent Tearing: Because
store.getState()is evaluated during render while subscriptions are attached asynchronously afterward, it cannot guarantee consistency across concurrent rendering slices.
The useSyncExternalStore Contract
useSyncExternalStore provides a synchronous, tear-free bridge between external data sources and React's concurrent scheduler:
const snapshot = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot?
);
The Three Parameters:
subscribe(callback): A function that accepts acallbackand subscribes to the store. React invokes this callback whenever the store updates. It must return an unsubscribe cleanup function.getSnapshot(): A synchronous function that returns the current snapshot of data from the store. React checks this value to determine if a re-render is required.getServerSnapshot?: An optional function returning the initial snapshot during Server-Side Rendering (SSR). If omitted, rendering on the server throws an error unless handled strictly on the client.
If the store mutates while a concurrent render is in progress, React detects that getSnapshot() returned a different value and re-starts the render synchronously, guaranteeing that all components in the tree display an identical snapshot.
Practical Example 1: Subscribing to Browser Online Status
Browser APIs like navigator.onLine are prime examples of external stores that can change at any moment:
'use client';
import { useSyncExternalStore } from 'react';
function subscribeOnline(callback: () => void) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function getOnlineSnapshot() {
return navigator.onLine;
}
function getServerOnlineSnapshot() {
// During SSR, assume online
return true;
}
export function useOnlineStatus() {
return useSyncExternalStore(
subscribeOnline,
getOnlineSnapshot,
getServerOnlineSnapshot
);
}
Now, any component can consume useOnlineStatus() safely without managing local useState or useEffect hooks:
export function NetworkIndicator() {
const isOnline = useOnlineStatus();
return (
<div className={`status-badge ${isOnline ? 'online' : 'offline'}`}>
{isOnline ? 'Connected' : 'Offline - Reconnecting...'}
</div>
);
}
Practical Example 2: Building a Custom Reactive Micro-Store
You can easily build lightweight, framework-agnostic global stores using useSyncExternalStore:
type Listener = () => void;
export function createStore<T>(initialState: T) {
let state = initialState;
const listeners = new Set<Listener>();
return {
getState: () => state,
setState: (updater: T | ((prev: T) => T)) => {
state = typeof updater === 'function'
? (updater as (prev: T) => T)(state)
: updater;
listeners.forEach((listener) => listener());
},
subscribe: (listener: Listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
// Instantiate a typed store
export const cartStore = createStore<{ itemCount: number; total: number }>({
itemCount: 0,
total: 0,
});
// React Hook consumer
export function useCart() {
return useSyncExternalStore(cartStore.subscribe, cartStore.getState);
}
The Fatal Pitfall: Unstable Snapshots and Infinite Loops
The most dangerous pitfall when using useSyncExternalStore is returning a newly allocated object or array inside getSnapshot():
// ❌ FATAL ERROR: Triggers an infinite re-render loop!
function getSnapshot() {
return {
count: externalStore.count, // New object reference on every invocation!
};
}
React compares snapshots using Object.is. If getSnapshot() returns a new object reference every time it is called, React assumes the store changed on every render cycle, resulting in:
"Maximum update depth exceeded."
The Rule
getSnapshot() must return an immutable value (string, number, boolean) or a cached object reference whose pointer only changes when the underlying data truly mutates.
Conclusion
useSyncExternalStore is a foundational primitive for robust React development. By moving away from fragile useEffect subscriptions and providing a synchronous, tear-free contract for external data sources, it ensures that your application remains rock-solid and visually consistent under concurrent rendering.