useSyncExternalStore
The Problem: Tearing in Concurrent React
Here's a subtle problem that didn't exist before React 18. Concurrent rendering can pause a render mid-way and resume later. If your component reads from an external mutable source (not React state), different parts of the same render might see different values — a bug called tearing.
// UNSAFE in concurrent React:
let externalCounter = 0;
function Counter() {
// This reads a mutable external value during render.
// In concurrent mode, React may pause between rendering
// Child1 and Child2. If externalCounter changes during
// the pause, Child1 shows 5 and Child2 shows 6.
return (
<div>
<Child1 value={externalCounter} />
<Child2 value={externalCounter} />
</div>
);
}
useSyncExternalStore solves this by forcing synchronous reads from external stores during render, preventing tearing.
Think of concurrent rendering as reading pages of a newspaper while someone is updating articles. Without synchronization, you might read page 1 with the old headline and page 3 with the updated headline — the newspaper is torn. useSyncExternalStore is like taking a snapshot of the entire newspaper before you start reading. Even if the newspaper updates while you read, you see a consistent version.
The API
const snapshot = useSyncExternalStore(
subscribe, // Function to subscribe to store changes
getSnapshot, // Function to read the current value
getServerSnapshot // Optional: Function to read value during SSR
);
Three requirements:
- subscribe(callback): registers a listener, returns an unsubscribe function
- getSnapshot(): returns the current value. Must return the same reference if nothing changed
- getServerSnapshot(): returns the value for server rendering
Basic Example: Window Width
function useWindowWidth() {
return useSyncExternalStore(
// subscribe: listen for resize events
(callback) => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
},
// getSnapshot: read current width
() => window.innerWidth,
// getServerSnapshot: default for SSR
() => 1024
);
}
function ResponsiveLayout() {
const width = useWindowWidth();
return width > 768 ? <DesktopLayout /> : <MobileLayout />;
}
Subscribing to External Stores
Browser Online Status
function useOnlineStatus() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
},
() => navigator.onLine,
() => true // Assume online during SSR
);
}
Custom External Store
function createStore(initialState) {
let state = initialState;
const listeners = new Set();
return {
getState: () => state,
setState: (newState) => {
state = typeof newState === 'function' ? newState(state) : newState;
listeners.forEach(listener => listener());
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
const counterStore = createStore({ count: 0 });
function CounterDisplay() {
const { count } = useSyncExternalStore(
counterStore.subscribe,
counterStore.getState,
counterStore.getState
);
return <p>Count: {count}</p>;
}
function IncrementButton() {
return (
<button onClick={() => counterStore.setState(s => ({ count: s.count + 1 }))}>
Increment
</button>
);
}
How Zustand uses useSyncExternalStore
Zustand, one of the most popular React state managers, uses useSyncExternalStore internally. When you call useStore(selector), Zustand:
- Subscribes to the store using
useSyncExternalStore - Uses the selector as part of
getSnapshotto extract the relevant slice - Compares snapshots with
Object.isto prevent unnecessary re-renders
This is why Zustand is concurrent-safe out of the box — it delegates the hard synchronization work to React.
The getSnapshot Contract
This is the part that trips people up. getSnapshot has a strict requirement: it must return the same reference if nothing has changed:
// WRONG — new object every call
const getSnapshot = () => ({
count: store.count,
name: store.name,
});
// This creates an infinite loop! Each call returns a new object,
// React thinks the store changed, re-renders, calls getSnapshot again...
// CORRECT — return the store object directly
const getSnapshot = () => store.getState();
// Same reference if nothing changed
// CORRECT — memoize selector results
function useStoreSelector(selector) {
const prevRef = useRef();
const getSnapshot = useCallback(() => {
const next = selector(store.getState());
if (Object.is(prevRef.current, next)) {
return prevRef.current;
}
prevRef.current = next;
return next;
}, [selector]);
return useSyncExternalStore(store.subscribe, getSnapshot);
}
If getSnapshot returns a new object reference on every call, useSyncExternalStore enters an infinite re-render loop. React calls getSnapshot, sees a "new" value (different reference), re-renders, calls getSnapshot again, gets another "new" value... Always return the same reference when the data has not changed.
Production Scenario: Media Query Hook
function useMediaQuery(query) {
const subscribe = useCallback(
(callback) => {
const mql = window.matchMedia(query);
mql.addEventListener('change', callback);
return () => mql.removeEventListener('change', callback);
},
[query]
);
const getSnapshot = useCallback(
() => window.matchMedia(query).matches,
[query]
);
return useSyncExternalStore(
subscribe,
getSnapshot,
() => false // Default for SSR
);
}
function Navigation() {
const isMobile = useMediaQuery('(max-width: 768px)');
return isMobile ? <HamburgerMenu /> : <FullNavBar />;
}
| What developers do | What they should do |
|---|---|
| Creating new objects in getSnapshot: () => ({ ...store }) New references on every call cause infinite re-render loops. useSyncExternalStore compares with Object.is. | Return the same reference when data has not changed |
| Using useState + useEffect to subscribe to external stores The useState + useEffect pattern can tear in concurrent React. useSyncExternalStore forces synchronous reads that prevent this. | Use useSyncExternalStore — it handles concurrent mode tearing |
| Forgetting getServerSnapshot for SSR Without getServerSnapshot, the hook throws during SSR because window/navigator/etc. are not available. | Always provide the third argument when using with Next.js or SSR frameworks |
| Putting subscribe inline without memoization: useSyncExternalStore((cb) => {...}, ...) A new subscribe function every render causes React to unsubscribe and resubscribe on every render. | Memoize subscribe with useCallback or define it outside the component |
- 1useSyncExternalStore prevents tearing in concurrent React by forcing synchronous reads
- 2getSnapshot must return the same reference when data has not changed — or you get infinite loops
- 3subscribe must be stable (memoized or static) to avoid unsubscribe/resubscribe churn
- 4Always provide getServerSnapshot for SSR compatibility
- 5Use this for browser APIs (resize, online, media queries) and external stores (Zustand, custom stores)
Challenge: Build a Local Storage Hook
Challenge: Synchronized Local Storage
// Build a hook that syncs state with localStorage,
// using useSyncExternalStore so it works across browser tabs.
// When one tab updates the value, other tabs should reflect the change.
function useLocalStorage(key, initialValue) {
// Your implementation here
// Hint: Use the 'storage' event for cross-tab sync
}
// Usage:
function App() {
const [name, setName] = useLocalStorage('username', 'Anonymous');
return <input value=`{name}` onChange=`{e => setName(e.target.value)}` />;
}
Show Answer
function useLocalStorage(key, initialValue) {
const getSnapshot = useCallback(() => {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
}, [key, initialValue]);
const subscribe = useCallback((callback) => {
const handler = (e) => {
if (e.key === key || e.key === null) {
callback();
}
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
}, [key]);
const getServerSnapshot = useCallback(() => initialValue, [initialValue]);
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const setValue = useCallback((newValue) => {
const resolved = typeof newValue === 'function' ? newValue(getSnapshot()) : newValue;
localStorage.setItem(key, JSON.stringify(resolved));
// Dispatch storage event for same-tab notification
// (storage event only fires in OTHER tabs by default)
window.dispatchEvent(new StorageEvent('storage', { key }));
}, [key, getSnapshot]);
return [value, setValue];
}Key details:
storageevent fires in OTHER tabs when localStorage changes- We manually dispatch a
StorageEventfor same-tab updates getSnapshotreads from localStorage on every call (it returns a primitive or the same parsed value)subscribelistens to thestorageevent and notifies useSyncExternalStore- Works across browser tabs — change in tab A updates tab B