A deep dive into React's most underrated hook — what it is, why it exists, and how to use it like a pro.
Table of Contents
- The Problem It Solves
- What Is
useSyncExternalStore? - API Signature
- A Simple First Example — Browser Tab Visibility
- Understanding the Three Arguments
- Server-Side Rendering & the
getServerSnapshot - Real-World Use Cases
- Why Not Just Use
useState+useEffect? - Tearing — The Core Problem This Hook Solves
- Building a Mini State Management Library
- Performance Considerations
- Common Mistakes & Pitfalls
- Compatibility & Polyfill
- Summary
The Problem It Solves
Before we jump into the API, let's understand why this hook was introduced in the first place.
React 18 shipped with Concurrent Mode — a new rendering engine that can pause, resume, and prioritize renders. This is fantastic for performance, but it introduced a subtle bug in how React components read from external (non-React) state sources.
Imagine you have a global store (like a Redux store, a Zustand store, or even a plain JS Map) and two components read from it. In Concurrent Mode, React might render these components at different points in time, meaning one component reads the old value while the other reads the new one. This inconsistency in the UI is called tearing, and it's visually jarring.
useSyncExternalStore was built specifically to eliminate tearing when subscribing to external stores.
What Is useSyncExternalStore?
useSyncExternalStore is a React 18 hook that lets your components safely subscribe to external data sources — anything that lives outside of React's own state management (useState, useReducer, useContext).
External sources include:
- Custom global stores
- Browser APIs (
navigator.onLine,document.visibilityState,localStorage) - Third-party libraries (Zustand, Redux, Valtio, etc.)
- Any pub-sub observable
It was formally introduced in React 18 but is also available as a standalone package (use-sync-external-store) for React 16 and 17.
API Signature
const snapshot = useSyncExternalStore(
subscribe: (callback: () => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot?: () => Snapshot
);
Three arguments, one return value. Clean and minimal.
A Simple First Example
Let's start with a classic: tracking whether the browser tab is visible or hidden.
import { useSyncExternalStore } from 'react';
function subscribe(callback: () => void) {
document.addEventListener('visibilitychange', callback);
return () => document.removeEventListener('visibilitychange', callback);
}
function getSnapshot() {
return document.visibilityState;
}
function usePageVisibility() {
return useSyncExternalStore(subscribe, getSnapshot);
}
// Usage in a component
function StatusBadge() {
const visibility = usePageVisibility();
return (
<span>
Tab is: <strong>{visibility === 'visible' ? '👁 Visible' : '🙈 Hidden'}</strong>
</span>
);
}
This is already cleaner and safer than the classic useEffect + useState approach. No race conditions, no tearing, and the subscribe function can be defined outside the component — meaning it doesn't get recreated on every render.
Understanding the Three Arguments
subscribe(callback)
This function is called once by React when the component mounts. It should:
- Attach a listener to your external store.
- Call
callbackwhenever the store changes. - Return a cleanup function that removes the listener.
function subscribe(callback: () => void) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
Critical rule: subscribe must be a stable reference (defined outside the component or wrapped in useCallback). If you pass a new function reference on every render, React will keep re-subscribing — causing an infinite loop.
getSnapshot()
This function is called by React to read the current value from the store. React calls it:
- During rendering to get the current value.
- After every store update to check if the value has changed.
function getSnapshot() {
return navigator.onLine; // returns true or false
}
Critical rule: getSnapshot must return the same value when called multiple times if the store hasn't changed. React uses Object.is comparison. If you return a new object reference every time (return { ...state }), React will re-render infinitely.
getServerSnapshot() (optional)
This is for Server-Side Rendering (SSR). Since browser APIs aren't available on the server, you need to provide a fallback value. If omitted and you use SSR, React will throw an error.
function getServerSnapshot() {
return true; // assume online on the server
}
Server-Side Rendering & the getServerSnapshot
If your app uses SSR (Next.js, Remix, etc.), the getServerSnapshot argument is not optional — it's essential.
Here's the full pattern for a safe, SSR-compatible hook:
import { useSyncExternalStore } from 'react';
function useNetworkStatus() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
},
() => navigator.onLine, // client snapshot
() => true // server snapshot (assume online)
);
}
On the server, React uses getServerSnapshot. On the client, it uses getSnapshot. This ensures hydration matches.
Real-World Use Cases
1. Subscribing to a Custom Store
Let's build a simple reactive counter store and consume it with useSyncExternalStore.
// store.ts
type Listener = () => void;
let count = 0;
const listeners = new Set<Listener>();
export const counterStore = {
getSnapshot: () => count,
subscribe: (listener: Listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
increment: () => {
count++;
listeners.forEach((l) => l());
},
decrement: () => {
count--;
listeners.forEach((l) => l());
},
};
// Counter.tsx
import { useSyncExternalStore } from 'react';
import { counterStore } from './store';
function Counter() {
const count = useSyncExternalStore(
counterStore.subscribe,
counterStore.getSnapshot
);
return (
<div>
<p>Count: {count}</p>
<button onClick={counterStore.increment}>+</button>
<button onClick={counterStore.decrement}>-</button>
</div>
);
}
Any number of Counter components rendered anywhere in your tree will always see the same value — no tearing, no stale reads.
2. Reading from localStorage Reactively
localStorage is a classic external store. Here's a hook that keeps a value in sync:
import { useSyncExternalStore, useCallback } from 'react';
function useLocalStorage<T>(key: string, initialValue: T) {
const subscribe = useCallback(
(callback: () => void) => {
window.addEventListener('storage', callback);
return () => window.removeEventListener('storage', callback);
},
[]
);
const getSnapshot = useCallback((): T => {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
}, [key, initialValue]);
const getServerSnapshot = useCallback((): T => initialValue, [initialValue]);
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const setValue = useCallback(
(newValue: T) => {
localStorage.setItem(key, JSON.stringify(newValue));
// Dispatch a storage event manually for same-tab updates
window.dispatchEvent(new Event('storage'));
},
[key]
);
return [value, setValue] as const;
}
Note: The native storage event only fires across tabs, not within the same tab. The manual dispatchEvent handles same-tab updates.
3. Syncing with a Third-Party Library
Say you're using a third-party pub-sub or observable. Here's how to wrap it:
import { useSyncExternalStore } from 'react';
import { someExternalEmitter } from 'some-library';
function useExternalData() {
return useSyncExternalStore(
(callback) => {
const unsubscribe = someExternalEmitter.on('change', callback);
return unsubscribe;
},
() => someExternalEmitter.getCurrentValue()
);
}
This is the pattern that state management libraries like Zustand and Redux use internally.
4. Network Online/Offline Status
import { useSyncExternalStore } from 'react';
const subscribe = (callback: () => void) => {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
};
export function useIsOnline() {
return useSyncExternalStore(
subscribe,
() => navigator.onLine,
() => true
);
}
// In a component
function NetworkBanner() {
const isOnline = useIsOnline();
if (isOnline) return null;
return (
<div style={{ background: 'red', color: 'white', padding: '8px', textAlign: 'center' }}>
You are offline. Some features may not be available.
</div>
);
}
Why Not Just Use useState + useEffect?
This is the most common question. You've probably written this pattern before:
// The classic pattern — looks fine, but has issues
function useIsOnline() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
This seems reasonable. But it has three subtle problems:
Problem 1 — The initial value might be stale. By the time the effect runs (after paint), the network status could have already changed. There's a window where the component renders with a stale value.
Problem 2 — Tearing in Concurrent Mode. In React 18 with Concurrent rendering, two components using this hook could read different values of isOnline if a network change happens mid-render.
Problem 3 — No SSR safety. Calling navigator.onLine inside useState initializer will throw during SSR because navigator doesn't exist on the server.
useSyncExternalStore solves all three of these at the framework level.
Tearing — The Core Problem This Hook Solves
Let's make tearing concrete. Imagine two components read a global variable externalCount:
React starts rendering Component A → reads externalCount = 5
...concurrent pause happens...
externalCount changes to 6
...rendering resumes...
React renders Component B → reads externalCount = 6
Now your UI shows two different values for the same piece of data. That's tearing. It's a race condition baked into the rendering model.
useSyncExternalStore tells React: "When you render any component subscribed to this store, make sure all of them see the same snapshot." React guarantees this by synchronizing the render.
This is what the word "Sync" in the hook name means — it forces React to synchronize with the external store, even in Concurrent Mode.
Building a Mini State Management Library
Let's go further and use useSyncExternalStore to build a fully functional, type-safe, minimal state manager in ~40 lines:
// createStore.ts
import { useSyncExternalStore } from 'react';
type SetState<T> = (updater: Partial<T> | ((prev: T) => Partial<T>)) => void;
type Selector<T, R> = (state: T) => R;
export function createStore<T extends object>(initialState: T) {
let state = initialState;
const listeners = new Set<() => void>();
const getState = () => state;
const setState: SetState<T> = (updater) => {
const partial =
typeof updater === 'function' ? updater(state) : updater;
state = { ...state, ...partial };
listeners.forEach((l) => l());
};
const subscribe = (callback: () => void) => {
listeners.add(callback);
return () => listeners.delete(callback);
};
function useStore(): T;
function useStore<R>(selector: Selector<T, R>): R;
function useStore<R>(selector?: Selector<T, R>): T | R {
return useSyncExternalStore(
subscribe,
selector ? () => selector(state) : getState
);
}
return { getState, setState, subscribe, useStore };
}
// Usage
const { useStore, setState } = createStore({
user: null as string | null,
theme: 'light' as 'light' | 'dark',
count: 0,
});
function ThemeToggle() {
const theme = useStore((s) => s.theme); // only re-renders when theme changes
return (
<button onClick={() => setState({ theme: theme === 'light' ? 'dark' : 'light' })}>
Switch to {theme === 'light' ? 'Dark' : 'Light'} Mode
</button>
);
}
function Counter() {
const count = useStore((s) => s.count); // only re-renders when count changes
return (
<button onClick={() => setState((s) => ({ count: s.count + 1 }))}>
Count: {count}
</button>
);
}
That's a fully working, selector-based, tear-free state manager in ~40 lines of code. This is essentially what Zustand does under the hood.
Performance Considerations
Define
subscribeoutside the component. Defining it inside causes a new function reference on every render, which triggers React to re-subscribe. Move it to module scope or wrap it inuseCallback.getSnapshotmust be pure and stable. React calls it frequently. Keep it fast — just return a value, don't compute expensive things inside it.Use selectors for large stores. Instead of subscribing to the entire store object, narrow down with a selector. React uses
Object.isto compare snapshots, so if the selected slice hasn't changed, no re-render happens.Avoid returning new objects from
getSnapshot. This is the most common performance bug:
// ❌ BAD — returns a new object reference every call
const getSnapshot = () => ({ ...store.state });
// ✅ GOOD — returns a stable primitive or the same reference
const getSnapshot = () => store.state.count;
Common Mistakes & Pitfalls
| Mistake | Why It's a Problem | Fix |
|---|---|---|
Defining subscribe inside the component |
New reference every render → infinite re-subscriptions | Move to module scope or use useCallback
|
Returning new objects from getSnapshot
|
Object.is always false → infinite re-renders |
Return primitives or stable references |
Forgetting getServerSnapshot in SSR apps |
React throws a hydration error | Always provide it for any browser API access |
Not returning the cleanup function from subscribe
|
Memory leak — listeners pile up indefinitely | Always return () => removeListener(...)
|
Using getSnapshot to compute derived state |
Can cause subtle bugs with stale closures | Compute derived values outside the hook |
Compatibility & Polyfill
useSyncExternalStore is available natively in React 18+.
For React 16.8–17, use the official shim:
npm install use-sync-external-store
// For React 16/17
import { useSyncExternalStore } from 'use-sync-external-store/shim';
// With server snapshot support
import { useSyncExternalStore } from 'use-sync-external-store/shim/with-selector';
The shim is maintained by the React team and is fully compatible.
Summary
useSyncExternalStore is the right tool whenever your component needs to read from anything that's not React state. It's not a niche hook for library authors only — it's a fundamental primitive for anyone doing:
- Custom hooks that read from browser APIs
- Integrating third-party stores or observables
- Building micro state management solutions
- Avoiding tearing in concurrent-mode apps
Here's the mental checklist: if you find yourself writing useEffect(() => { externalThing.subscribe(...) }, []) to keep local state in sync with something outside React — reach for useSyncExternalStore instead. It's safer, cleaner, and React 18-compatible from the ground up.
Happy building. Keep your stores external and your snapshots stable.
Top comments (0)