Not all state lives in useState. Sometimes it's in a store, a browser API (navigator.onLine, matchMedia), a WebSocket, or a cache — state that exists outside React and changes on its own. The clean way to plug that into a component is useSyncExternalStore, the React 18 hook Redux, Zustand, and Jotai all build on. So I built a demo where the store is plain JS and you can watch React stay in sync with it.
▶ Live demo: https://usesyncexternalstore.vercel.app/
Source: https://github.com/dev48v/usesyncexternalstore
A store that knows nothing about React
let state = { count: 0 };
const listeners = new Set();
const store = {
getSnapshot: () => state,
subscribe: (cb) => { listeners.add(cb); return () => listeners.delete(cb); },
increment: () => {
state = { ...state, count: state.count + 1 }; // new object each change
listeners.forEach((l) => l()); // notify subscribers
},
};
No hooks, no React import. Just a value, a set of listeners, and immutable updates that notify.
One hook to subscribe
function Counter() {
const count = useSyncExternalStore(
store.subscribe, // 1. how React listens
() => store.getSnapshot().count // 2. what this component reads
);
return <span>{count}</span>;
}
useSyncExternalStore takes two functions: subscribe (register a callback, return an unsubscribe) and getSnapshot (read the current value). React handles the rest — it subscribes on mount, unsubscribes on unmount, and re-renders when the snapshot changes.
In the demo, two components read count from the same store. Click any button — including one that just calls store.increment() and sets no React state — and both update in sync. There's one source of truth, and it lives outside React.
The selector detail that matters
getSnapshot should return the smallest thing the component needs, and it must be referentially stable when unchanged — return a primitive, or a memoized reference. If you return a fresh object/array every call, React thinks the value changed every time and can re-render forever (or throw "getSnapshot should be cached").
The payoff of a narrow selector: in the demo a setInterval outside React bumps a separate ticks field every second. The two count components don't re-render on those ticks — their selector returns the same count, so React bails out. Only the component reading ticks re-renders. Watch the per-component render counters: the counters hold steady while the ticker climbs. That's fine-grained subscription with no extra library.
Why not just useState + useEffect?
The pre-18 pattern was to subscribe in an effect and mirror the snapshot into state:
const [snap, setSnap] = useState(store.getSnapshot());
useEffect(() => store.subscribe(() => setSnap(store.getSnapshot())), []);
It works for simple cases, but under concurrent rendering it can tear: React can pause and resume a render, and different components (or different parts of one render) may read the store at different moments, showing inconsistent values in the same frame. useSyncExternalStore exists specifically to prevent that — it guarantees every component reads the same consistent snapshot within a render, and forces the update to be synchronous when correctness requires it. It also takes an optional third argument, getServerSnapshot, so the same store works with SSR/hydration.
When you'd reach for it
- Wrapping a browser API as a hook:
useOnlineStatus,useMediaQuery,useWindowSize. - Connecting a framework-agnostic store (your own, or a vanilla Zustand/Redux store) to React.
- Any external, mutable source you want components to observe without prop-drilling or context churn.
You don't need it for ordinary component state — useState/useReducer are simpler. Reach for it when the source of truth is genuinely outside React.
Flip the counters, watch the ticker drive renders on its own, and mutate __store from the console. If it made external stores click, a star helps others find it: https://github.com/dev48v/usesyncexternalstore
Top comments (0)