Custom Effects Hooks: Demystifying useSyncExternalStore to Build Resilient, Framework-Agnostic State Listeners
If you've ever synced a React component to window.innerWidth, a WebSocket connection, localStorage, or a third-party state container, you've probably reached for the same combo: useEffect to subscribe, useState to hold the value, and a cleanup function to unsubscribe. It works — until it doesn't. Under React 18's concurrent rendering, that pattern can quietly produce tearing: different parts of your UI rendering with different, conflicting snapshots of the same external value at the same instant.
useSyncExternalStore is React's purpose-built answer to this problem. It's not a flashy hook, and it doesn't show up in beginner tutorials next to useState and useEffect. But if you're building custom hooks that read from anything outside React — browser APIs, global variables, third-party stores, WebSocket clients — it's the one hook engineered specifically to keep those subscriptions correct, consistent, and portable across rendering strategies.
This guide breaks down exactly what useSyncExternalStore does, why it exists, and how to use it to build genuinely framework-agnostic state listeners: hooks whose subscription logic could run unmodified in Preact, a vanilla JS class, or an entirely different UI layer, with React acting only as the final rendering step.
This looks reasonable, and for years it was the accepted approach. The trouble starts with React's concurrent features. React can now pause a render, work on something else, and resume later. If the external store (in this case, the browser window) changes state while React is mid-render, two components consuming the same hook can end up rendering with different values in the same commit. This is a race condition built directly into the rendering model, and the visible symptom is the UI displaying two different values for what should be a single piece of data — a bug commonly called "tearing."
There's a second, quieter problem too: server-side rendering. Calling window.innerWidth or navigator.onLine directly inside a hook's initial state throws on the server, because those globals don't exist there. Developers ended up writing awkward typeof window !== 'undefined' guards scattered across dozens of custom hooks.
React needed a primitive that could:
Guarantee every component reading the same external value sees the same snapshot during a single render pass, even under concurrent scheduling.
Provide a clean, built-in escape hatch for server rendering.
Work the same way regardless of what the external store actually is.
That primitive is useSyncExternalStore.
What useSyncExternalStore Actually Does
useSyncExternalStore is a React Hook that lets a component subscribe to an external store. Its signature is intentionally minimal:
It requires two functions and accepts an optional third: subscribe should register a listener on the store and return an unsubscribe function, and getSnapshot should read and return the current value from the store. React calls getSnapshot on every render to check whether the value has changed, and re-renders the component only when the returned value is different (compared with Object.is).
According to the official React documentation, this hook is intended for two broad categories of use case: third-party state management libraries that hold state outside of React, and browser APIs that expose a mutable value along with events you can subscribe to for changes.
That second category is exactly where framework-agnostic custom hooks live — navigator.onLine, window.matchMedia, document.visibilityState, localStorage, a WebSocket's connection state, or a plain JavaScript class instance that manages its own data and notifies subscribers.
Anatomy of the Hook: Three Functions, One Contract
To use useSyncExternalStore correctly, you need to understand the contract each argument must fulfill.
1. subscribe(callback)
This function receives a callback from React and is responsible for calling that callback whenever the store's data changes. It must return a cleanup function that removes the listener. React calls subscribe once when the component mounts and again if the subscribe function reference itself changes — which is why it must be stable across renders (more on this in the pitfalls section).
2. getSnapshot()
This function returns the current value of the store, synchronously, with no side effects. React calls it on nearly every render to check if a re-render is warranted. Two rules matter enormously here:
The value must be immutable or at least treated as immutable — if the store's internal data has changed, getSnapshot must return a new reference or a primitive that differs from the last one.
If nothing has changed, getSnapshot must return the exact same reference (or an equal primitive) it returned last time. Returning a brand-new object on every call — even one with identical contents — causes React to think the store changed on every render, triggering an infinite re-render loop.
3. getServerSnapshot() (optional but often mandatory)
Used exclusively during server-side rendering and hydration, this function should return a fallback value that doesn't depend on browser-only globals. If your app uses SSR — Next.js, Remix, or any framework that renders on the server — and you omit this for a hook that touches window or navigator, React will throw a hydration error, because there's nothing sensible for it to return on the server.
Building a Framework-Agnostic Store From Scratch
The real power of useSyncExternalStore is that it forces a clean separation between state ownership and state consumption. The store itself should be plain JavaScript with zero knowledge of React. Here's a minimal, resilient store implementation you could drop into any environment — Node, a Web Worker, a vanilla JS app, or a React tree:
// store.js — completely framework-agnostic
export function createStore(initialValue) {
let state = initialValue;
const listeners = new Set();
function getSnapshot() {
return state;
}
function setState(updater) {
const nextState = typeof updater === 'function' ? updater(state) : updater;
if (Object.is(nextState, state)) return;
state = nextState;
listeners.forEach((listener) => listener());
}
function subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
return { getSnapshot, setState, subscribe };
}
Notice there's no import from react anywhere in this file. It could be tested with plain assertions, reused in a CLI tool, or wired into another framework's reactivity system. That's the entire point of framework-agnostic design: the store doesn't care who's listening.
Wiring the Store Into a Resilient Custom Hook
Now the React-specific layer becomes a thin adapter:
import { useSyncExternalStore } from 'react';
import { createStore } from './store.js';
const counterStore = createStore(0);
export function useCounter() {
return useSyncExternalStore(counterStore.subscribe, counterStore.getSnapshot);
}
export function increment() {
counterStore.setState((count) => count + 1);
}
Any component calling useCounter() will always render the same, current value as every other component calling it in the same commit — no tearing, no stale reads, no manual useEffect bookkeeping. If you later swap this app onto a different rendering library that supports the same external-store contract, the store.js file doesn't change at all.
Common Real-World Hooks Built This Way
Online/Offline Status
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function getSnapshot() {
return navigator.onLine;
}
function getServerSnapshot() {
return true; // reasonable default on the server
}
export function useOnlineStatus() {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
Each of these follows the identical shape: a subscribe function that hooks into a native browser event, a getSnapshot function that reads the current truth, and (where relevant) a getServerSnapshot fallback. That consistency is what makes the pattern teachable and reusable across an entire codebase.
The Snapshot Trap: Object Identity and Infinite Loops
The single most common bug when adopting useSyncExternalStore is returning a new object or array from getSnapshot on every call:
// ❌ Creates a new object every time — infinite re-render loop
function getSnapshot() {
return { width: window.innerWidth, height: window.innerHeight };
}
Because React compares snapshots with Object.is, a fresh object reference is "different" every time, even if width and height haven't changed. React interprets this as "the store changed," re-renders, calls getSnapshot again, gets another fresh object, and loops.
Fix it by caching the result and only creating a new object when the underlying primitives actually change:
The same discipline applies to subscribe and getSnapshot themselves: if you define them inline inside your component without useCallback, they become new function references on every render, which can cause the hook to resubscribe unnecessarily. Always define them outside the component, or memoize them when they depend on props.
Server-Side Rendering Considerations
If your custom hooks touch window, document, or navigator and your app renders on the server, treat getServerSnapshot as non-negotiable rather than optional. On first client render, React uses the server snapshot to avoid a hydration mismatch, then switches to the real client snapshot on the next paint. Skipping this argument for a browser-dependent store will surface as a hydration warning or an outright crash the moment the app is deployed behind SSR — a bug that's easy to miss in local development if you only test with a plain client-side dev server.
How State Management Libraries Use It Internally
You may already be using useSyncExternalStore without realizing it. Modern versions of Zustand, Jotai, and Redux's React bindings (react-redux) all use it internally to connect their external stores to React components, precisely because it solves tearing for free. Understanding the raw hook demystifies what these libraries are doing under the hood: they're maintaining a plain JavaScript store with a subscribe/getState contract and letting useSyncExternalStore handle the React-specific wiring. If you've ever wondered how Redux avoided rewriting its subscription model for concurrent React, this hook is the answer.
useSyncExternalStore vs. useEffect + useState
Concern
useEffect + useState
useSyncExternalStore
Tearing under concurrent rendering
Possible
Prevented by design
SSR-safe fallback
Manual guards required
Built-in getServerSnapshot
Re-render timing
After paint (effect runs post-commit)
Synchronous with the render itself
Boilerplate for cleanup
Manual useEffect return
Same contract, but standardized
Ideal for
Component-local side effects (fetching, logging, DOM imperative APIs)
Subscribing to state that lives outside React entirely
The two aren't interchangeable. useEffect remains the right tool for side effects that don't represent a piece of read-and-subscribe state — sending analytics events, focusing an input, or triggering an animation. useSyncExternalStore is specifically for the "read a value, get notified when it changes" shape of problem. If you're also digging into how React schedules updates around external changes, it's worth pairing this with a look at concurrent rendering and how useTransition and useDeferredValue manage interruptible updates — the tearing problem useSyncExternalStore solves only exists because of the scheduling model those two hooks also interact with.
Testing Your External Store Listeners
Because the store logic is framework-agnostic, you can test it without rendering a single React component:
import { createStore } from './store.js';
test('store notifies subscribers on change', () => {
const store = createStore(0);
const listener = jest.fn();
const unsubscribe = store.subscribe(listener);
store.setState(5);
expect(listener).toHaveBeenCalledTimes(1);
expect(store.getSnapshot()).toBe(5);
unsubscribe();
store.setState(10);
expect(listener).toHaveBeenCalledTimes(1); // no longer subscribed
});
This is a direct benefit of keeping the store decoupled from React: your core state logic gets fast, framework-free unit tests, while a thinner set of integration tests can verify the hook itself renders correctly.
Best Practices Checklist
Keep the store implementation free of any React imports — it should be portable by design.
Never return a new object/array from getSnapshot unless the underlying data actually changed.
Define subscribe and getSnapshot outside the component, or memoize them with useCallback when they depend on props.
Always supply getServerSnapshot for any store touching browser-only globals if your app can render on the server.
Avoid triggering Suspense from a value returned by useSyncExternalStore — mutations to external stores can't be treated as non-blocking transitions, so this typically produces a jarring loading-spinner UX instead of a smooth update.
Write unit tests against the plain store object before writing tests against the hook.
If your consuming components are re-rendering more than expected, check whether the issue is actually store-driven or a context-provider problem — splitting contexts and memoizing provider values solves a related but distinct class of re-render bug.
Frequently Asked Questions
Is useSyncExternalStore only for library authors?
No. While it's the backbone of libraries like Redux and Zustand, it's equally useful for application-level custom hooks that read from browser APIs, WebSockets, or any shared mutable state outside React's own state system.
Do I need it for simple useState logic?
No. If the state originates and lives entirely inside a React component, plain useState is simpler and sufficient. Reach for this hook only when the source of truth lives outside React.
What happens if I skip getServerSnapshot in a client-only app?
Nothing bad — it's optional and only relevant during server rendering. In a pure client-side single-page app, you can safely omit it.
Can getSnapshot be asynchronous?
No. It must run synchronously and return a value immediately. For asynchronous data sources, maintain a synchronous cached value in your store that gets updated when the async operation resolves, then notify subscribers.
Conclusion
useSyncExternalStore isn't a hook you'll reach for every day, but it's the correct tool the moment your component needs to reflect data that React doesn't own — a browser API, a WebSocket, or a hand-rolled store shared across your app. By keeping the store itself framework-agnostic and treating the hook purely as a thin adapter, you get subscription logic that's easier to test, safer under concurrent rendering, and portable well beyond any single version of React.
3Demystifying the Rust Borrow Checker: Fix Lifetime Errors Fast