State libraries, browser APIs and shared caches all live outside React; subscribing to them correctly is required for concurrent rendering.
Some state isn't owned by React: a Redux or Zustand store, navigator.onLine, window.matchMedia, a WebSocket cache, localStorage. The naive approach (copy it into state in an effect and subscribe) causes a render with stale data first, and, worse, tearing under concurrent rendering: if the store changes while React is rendering a transition in slices, components rendered before and after the change show different values in the same commit.
`useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)` solves this:
subscribe(callback)registers a listener React calls whenever the store changes, and returns an unsubscribe function.getSnapshot()returns the current value. React calls it during render and checks it again before committing; if the value changed mid-render, React re-renders synchronously so the whole tree sees one consistent value.getServerSnapshot()returns the value to use during server rendering and hydration (where browser APIs don't exist); without it, server rendering throws.
Rules that trip people up:
getSnapshotmust return the same value (byObject.is) when the store hasn't changed. Returning a new object or array each call ({ ...state },items.filter(...)) causes an infinite loop. Return immutable snapshots and select or derive outside, or cache.- Define
subscribeoutside the component (or memoize it); a new function each render re-subscribes every time. - Updates to external stores are always synchronous: they can't be marked as transitions, so a store change that suspends shows the fallback rather than keeping old UI.
This is the primitive state libraries build on; Redux's useSelector and Zustand's hooks are implemented with it. Wrap it in a custom Hook (useOnlineStatus) and components never know where the data lives.
Example
import { useSyncExternalStore } from "react";
// A tiny store living outside React
function createStore(initial) {
let state = initial;
const listeners = new Set();
return {
getState: () => state, // same object until changed
setState(update) {
state = { ...state, ...update(state) }; // new immutable snapshot
listeners.forEach(l => l());
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
const cartStore = createStore({ count: 0 });
function useCartCount() {
return useSyncExternalStore(
cartStore.subscribe,
() => cartStore.getState().count, // primitive: stable by Object.is
() => 0 // server snapshot
);
}
export default function CartBadge() {
const count = useCartCount();
return <button onClick={() => cartStore.setState(s => ({ count: s.count + 1 }))}>Cart ({count})</button>;
}Remember
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)reads external state without tearinggetSnapshotmust return an identical value while the store is unchanged- Keep
subscribestable; external store updates can't be transitions
Watch out for
- Returning a freshly built object or filtered array from getSnapshot, causing an infinite re-render loop.