“How would you connect a non-React store, like a WebSocket feed or localStorage, to your components?” Older answers use useState plus a useEffect subscription. Interviewers for senior roles want to hear useSyncExternalStore, and more importantly why: concurrent rendering can otherwise show inconsistent values across the screen, a problem called tearing.
This is also how libraries such as Redux (via react-redux) and Zustand connect to React today, so explaining it shows you understand what happens beneath the state library you use daily.
Before you start
You should know useEffect subscriptions with cleanup (see effect cleanup), closures, and the idea that React 18 introduced concurrent rendering, where React can pause a render and resume it later. Familiarity with the observer pattern (subscribe, notify, unsubscribe) helps. The hook was added in React 18 and is unchanged in React 19.
The short answer
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?) lets a component read from a store that lives outside React. subscribe(callback) registers a listener and returns an unsubscribe function; getSnapshot() returns the current value. React calls getSnapshot during render and again before committing, and if the store changed mid-render it re-renders synchronously so every component shows the same version. getSnapshot must return a cached value (the same reference until the store actually changes), otherwise React sees a new value every time and loops. getServerSnapshot supplies the value for server rendering and hydration.
How it works
A minimal store needs three things: a current value, a set of listeners, and a way to notify them:
function createStore(initial) {
let state = initial;
const listeners = new Set();
return {
getSnapshot: () => state,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
setState(update) {
const next = typeof update === 'function' ? update(state) : update;
if (Object.is(next, state)) return;
state = next;
listeners.forEach(l => l());
},
};
}Immutable updates make getSnapshot naturally cached: it returns the same object until setState replaces it. A component reads it like this:
const counterStore = createStore({ count: 0 });
function Count() {
const state = useSyncExternalStore(counterStore.subscribe, counterStore.getSnapshot);
return <p>{state.count}</p>;
}Why not useState plus useEffect? During a concurrent render, React may render half the tree, yield to the browser, and continue. If the store changes in between, components rendered before the change show the old value and those after show the new one. That is tearing. useSyncExternalStore checks the snapshot again before commit and forces a synchronous, consistent render if it changed. The effect approach also renders once with the initial value and then again after subscribing, and can miss an update that happens between render and subscription.
Step-by-step walkthrough
Step 1: Keep subscribe stable
If you define subscribe inline in the component, it is a new function every render and React will unsubscribe and resubscribe each time. Define it at module level or wrap it in useCallback:
function subscribeOnline(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
return useSyncExternalStore(
subscribeOnline,
() => navigator.onLine,
() => true // server: assume online
);
}navigator.onLine is a boolean, so the snapshot is trivially stable.
Step 2: Return cached snapshots, never fresh objects
This selector creates a new object every call:
// Loops: "The result of getSnapshot should be cached"
const view = useSyncExternalStore(store.subscribe, () => ({
count: store.getSnapshot().count,
}));React compares consecutive snapshots with Object.is, sees a difference every time, and re-renders until it throws maximum update depth exceeded. Select a primitive or an existing reference instead:
const count = useSyncExternalStore(store.subscribe, () => store.getSnapshot().count);Step 3: Handle sources that do not return stable values
localStorage.getItem returns a string, which compares by value, so it is fine. A source like JSON.parse(localStorage.getItem('prefs')) produces a new object every call; cache it by the raw string:
let lastRaw, lastParsed;
function getPrefsSnapshot() {
const raw = localStorage.getItem('prefs');
if (raw !== lastRaw) {
lastRaw = raw;
lastParsed = raw ? JSON.parse(raw) : {};
}
return lastParsed;
}Step 4: Provide a server snapshot
Without getServerSnapshot, server rendering throws for this hook. The server snapshot must return the same value on the server and during client hydration; if the real client value differs, React renders the server value for hydration and then updates. That avoids a hydration mismatch.
Worked scenario
A trading dashboard subscribed to a price feed in each widget with useState and useEffect. During heavy updates, a header total and a table row occasionally disagreed for a moment, and screenshots in bug reports showed a total that did not match the visible rows. Under React 18’s concurrent features (a transition was used for switching tabs), parts of the tree rendered at different times and read different prices.
The team created one store for the feed and replaced each widget’s subscription:
const price = useSyncExternalStore(
priceStore.subscribe,
() => priceStore.getSnapshot().prices[symbol]
);The first deploy froze the tab: one widget used () => ({ ...priceStore.getSnapshot().prices }) to copy the map, creating a new object per call. Returning the store’s existing prices object (replaced immutably on each tick) fixed it. Totals and rows now always matched because React guarantees a consistent snapshot per commit.
Common mistake
- Returning a new object or array from
getSnapshot. The most common bug; it causes infinite re-renders. - Inline
subscribe. Correct but wasteful; React resubscribes on every render. - Mutating store state in place.
getSnapshotreturns the same reference, so React never sees the change. - Using it for state React owns. If the data originates in React components,
useState,useReduceror context are simpler. - Forgetting
getServerSnapshotin server-rendered apps. Rendering fails on the server without it.
Verify the behavior
Test the store in Node first:
const store = createStore({ count: 0, user: 'ada' });
let calls = 0;
const unsub = store.subscribe(() => calls++);
store.setState(s => ({ ...s, count: s.count + 1 }));
store.setState(s => s); // no change, no notification
console.log(store.getSnapshot(), calls); // { count: 1, user: 'ada' } 1
unsub();
store.setState(s => ({ ...s, count: 5 }));
console.log(calls); // 1Then render two components reading the same store, update it from a button inside startTransition, and confirm in React DevTools that both always show the same value. To see the guard, return { ...store.getSnapshot() } from getSnapshot and watch React log that the result should be cached, then throw.
Follow-up questions
What is tearing? Showing different versions of the same data in one committed UI, possible when a concurrent render reads mutable external data at different times.
Does useSyncExternalStore work with transitions? Store updates are always synchronous; they cannot be marked as non-blocking transitions. If a store update would suspend, React shows the nearest fallback instead of keeping old content.
How do libraries add selectors? They wrap the hook with a selector and an equality check that caches the selected value, such as useSyncExternalStoreWithSelector from the use-sync-external-store package.
Why not context for a high-frequency feed? Context re-renders every consumer on any change; a store with selectors re-renders only components whose selected slice changed. See context scope.
Interview exercise
Write a useMediaQuery(query) hook with useSyncExternalStore that works with server rendering. State one subtle issue to watch for.
Answer and reasoning
function useMediaQuery(query) {
const subscribe = useCallback(callback => {
const mql = window.matchMedia(query);
mql.addEventListener('change', callback);
return () => mql.removeEventListener('change', callback);
}, [query]);
return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
() => false
);
}subscribe is memoised on query so React resubscribes only when the query changes. The snapshot is a boolean, so it is stable. The server snapshot returns false, and the subtle issue is that on a device where the query matches, hydration renders false first and then switches to true, so layout may shift once. Choose a server default that matches most users, or make the affected UI non-critical. Calling matchMedia in getSnapshot on every render is cheap enough, but you could cache the MediaQueryList per query.
Continue learning
Explore the React interview questions and the React MCQs. Related notes: effect cleanup, context scope and transitions and urgent updates. Official reference: the useSyncExternalStore reference on react.dev.