useSyncExternalStore is the React hook that subscribes a component to a store that lives outside React — a Redux store, a browser API, a plain object with listeners — and returns a consistent snapshot that re-renders the component whenever that store changes. Your job is to build the client shim: the exact userland implementation React ships as use-sync-external-store/shim for versions that predate the built-in hook. You implement it out of the smaller primitives (useState, useEffect, useLayoutEffect) — not by calling React's built-in useSyncExternalStore, which would be cheating the whole point.
The hard part is a one-frame gap. A component reads the store's value while it renders, then subscribes to the store slightly later, in an effect. If the store changes in between, a naive hook that only waits for future notifications never hears about that change — the screen shows a stale value, a bug React calls tearing. The shim closes the gap by re-reading the snapshot at the moment it subscribes.
function useSyncExternalStore<T>(
// Registers a callback the store calls on every change; returns an unsubscribe fn.
subscribe: (onStoreChange: () => void) => () => void,
// Returns the store's current, immutable snapshot. Must be cached for unchanged state.
getSnapshot: () => T,
): T // the current snapshot
// A store outside React: a value, a Set of listeners, and a notify.
const store = createStore(0); // { getSnapshot, subscribe, set }
function Counter() {
const count = useSyncExternalStore(store.subscribe, store.getSnapshot);
return <span>{count}</span>;
}
store.set(1); // every mounted Counter re-renders and shows 1
// The snapshot is compared with Object.is. Notifying with an equal value
// wakes nobody; only a genuinely different snapshot re-renders.
const same = { n: 1 };
const store = createStore(same);
// store.set(same) -> Object.is(same, same) is true -> no re-render
// store.set({ n: 1 }) -> a different reference -> re-render
getSnapshot() right before you subscribe.Object.is, never deep equality. Re-render only when the snapshot's identity changes. An equal snapshot must not re-render.subscribe changes identity. Key the subscription effect on subscribe, and clean up the previous subscription first.getSnapshot must return a cached value for unchanged state. A getSnapshot that builds a fresh object each call is never Object.is-equal to itself and loops forever — that constraint is on the caller, but your hook must not paper over it.getServerSnapshot (used for SSR/hydration) — the core is the client path. It appears in Going further.You will connect a React component to a store that lives outside React, and the entire difficulty is one gap: the store can move between the moment you read it and the moment you start listening.
React components only know how to re-render when React's own state changes. But plenty of state lives outside React — a Redux store, navigator.onLine, a WebSocket, a plain object with a list of listeners. To show that state, a component has to do two things: read the current value, and subscribe so it re-renders on the next change.
The trap is that these happen at two different times. You read the value while the component renders. You subscribe a beat later, inside an effect, after the render has been painted. If the store changes in that sliver of time — and it can, especially when another component's effect writes to the same store during the same commit — a hook that only waits for future notifications will never hear about that one change. It rendered the old value and subscribed too late to catch the new one. The screen is now lying, and no amount of waiting fixes it, because the notification already happened. React has a name for this: tearing.
Think of it as reading a noticeboard and then signing up for alerts. You read the board (the snapshot), then walk over to the sign-up sheet (subscribe). If someone updates the board while you are walking, your alert subscription starts after that update — you will get every future change but you already missed this one, and you are standing there believing the stale thing you read.
The fix is one sentence: when you finally subscribe, re-read the snapshot and compare it against the value you rendered — if it moved, re-render immediately.
Here is the version almost everyone writes first, and it is a good instinct — it is exactly useState plus a subscription:
const { useState, useEffect } = require('react');
function useSyncExternalStore(subscribe, getSnapshot) {
const [snapshot, setSnapshot] = useState(getSnapshot());
useEffect(() => {
return subscribe(() => setSnapshot(getSnapshot()));
}, []);
return snapshot;
}
Read it again, because it looks finished. It returns the right value on mount. When the store notifies, it re-reads and re-renders. It even bails out on an equal value for free, because setSnapshot of an Object.is-equal value is a no-op in React. It passes the basic tests.
And it has three holes, all of which come from the same mistake — it reads the store exactly once and then stops looking. (1) It captures getSnapshot() at mount, so a change in the render-to-subscribe gap is lost forever — the tear. (2) The [] deps mean it never re-subscribes; hand it a new subscribe (a different store) and it keeps listening to the old one. (3) It replays a value captured at mount instead of reading fresh each render. The first hole is the one that matters, and it is invisible: nothing throws, the code looks correct, and the value is simply wrong.
const { useState, useEffect, useLayoutEffect } = require('react');
// Re-read the store and report whether OUR last-seen snapshot still matches it.
// A throwing getSnapshot counts as "changed" so the render can surface the error.
function checkIfSnapshotChanged(inst) {
const latestGetSnapshot = inst.getSnapshot;
const prevValue = inst.value;
try {
const nextValue = latestGetSnapshot();
return !Object.is(prevValue, nextValue);
} catch (error) {
return true;
}
}
function useSyncExternalStore(subscribe, getSnapshot) {
// Read the snapshot during render — every render, synchronously. This is both
// the value we return and the value the effects compare against.
const value = getSnapshot();
// A mutable record of what we last read and how to read it again. React's own
// shim stashes this in a useState slot to save a hook; a ref is the same idea.
// We never use the state it holds — only the fact that setting a fresh {inst}
// forces a re-render.
const [{ inst }, forceUpdate] = useState(() => ({
inst: { value, getSnapshot },
}));
// Keep the record current in the LAYOUT phase, then check for a mutation that
// landed between render and commit.
useLayoutEffect(() => {
inst.value = value;
inst.getSnapshot = getSnapshot;
if (checkIfSnapshotChanged(inst)) forceUpdate({ inst });
}, [subscribe, value, getSnapshot]);
useEffect(() => {
// The store may have moved between render and this effect. Check BEFORE we
// subscribe — otherwise that change is only in the store, never in the UI,
// and no future notification will mention it. This is the tear the shim
// exists to close.
if (checkIfSnapshotChanged(inst)) forceUpdate({ inst });
const handleStoreChange = () => {
// The store notified. Re-render only if OUR snapshot actually moved.
if (checkIfSnapshotChanged(inst)) forceUpdate({ inst });
};
// Subscribe, returning the store's unsubscribe as cleanup. Because the
// effect is keyed on `subscribe`, a new subscribe identity tears down the
// old subscription and builds a fresh one.
return subscribe(handleStoreChange);
}, [subscribe]);
return value;
}
module.exports = { useSyncExternalStore };
Three shifts turn the first attempt into the real thing. First, the snapshot is read during render, every render (const value = getSnapshot()) rather than once at mount — so a plain re-render always shows the live value. Second, a small mutable record, inst, remembers the last value and the latest getSnapshot; checkIfSnapshotChanged is the one function that re-reads and compares. Third — and this is the whole question — that same check runs right before we subscribe, so a change in the gap is caught instead of missed.
Notice forceUpdate({ inst }): we pass a brand-new wrapper object every time. React compares state with Object.is, and a fresh object is never equal to the last one, so this reliably re-renders even though the inst inside is the same mutable record we have been reading all along. The state slot is not storing state — it is a re-render button that also happens to hold our bookkeeping.
Take const store = createStore('a') and one Counter reading it.
value = getSnapshot() is 'a'. The useState initializer stashes inst = { value: 'a', getSnapshot }. The hook returns 'a'.inst.value = 'a', inst.getSnapshot = getSnapshot, then checkIfSnapshotChanged re-reads: 'a' vs 'a', no change.'a' vs 'a', no change. Then subscribe(handleStoreChange) registers the callback and its unsubscribe becomes the cleanup. The store now holds one listener.store.set('b'). The store updates its value and calls every listener. handleStoreChange runs, checkIfSnapshotChanged reads 'b' against inst.value of 'a', they differ, so forceUpdate({ inst }) fires.value = getSnapshot() is now 'b'; the hook returns 'b'. The layout effect sets inst.value = 'b' and the check finds no further change. One change, one re-render, showing 'b'.Now the tear. Suppose that between step 1 and step 3, another component's effect had already called store.set('b'). There is no listener yet, so that notification reaches nobody. But step 3's check before subscribing re-reads the store — 'b' against the 'a' we rendered — sees the difference, and forces a re-render to 'b'. The first attempt has no such check: it subscribes and returns the 'a' it captured at mount, and stays wrong until some unrelated future write happens to nudge it.
getSnapshot that builds a fresh value every call. () => store.items.filter(...) returns a new array each time, is never Object.is-equal to itself, and the shim re-checks, re-renders, re-checks... until React throws Maximum update depth exceeded. This is a crash, not a slow page. Fix: cache the snapshot in the store so unchanged state returns the same reference, and only allocate a new one when the data actually changes.subscribe or getSnapshot as inline arrows. If the caller passes useSyncExternalStore((cb) => store.subscribe(cb), () => store.get()), both are new identities on every render, so the subscription effect (keyed on subscribe) tears down and rebuilds constantly. Fix: the store should expose stable subscribe and getSnapshot references, defined once — never recreated per render.=== or a deep equal instead of Object.is. Object.is differs from === on exactly NaN (equal under Object.is, not under ===) and -0 vs +0. React uses Object.is; matching it avoids a spurious re-render on a NaN snapshot. A deep equal would be far too expensive to run on every notification.subscribe change. With [] deps the hook is glued to the first store forever. Keying the effect on subscribe (and cleaning up first) is what lets the same component point at a different store later.getServerSnapshot for SSR. The real hook takes a third argument used during server render and hydration, where there is no store to subscribe to and getSnapshot might touch browser-only APIs. The shim branches to it when window is absent; React's non-shim build wires it into hydration so the server and first client render agree.useSyncExternalStoreWithSelector. A sibling that adds a selector and an isEqual, keeping a second cache so a component only re-renders when its slice changes. This is what createStore's useSelector and react-redux are built on — they consume the box you just built.useSyncExternalStore cooperates with concurrent rendering to prevent tearing within a single render pass — something userland cannot fully replicate. The shim is the best-effort version for React 17 and below, correct under synchronous rendering, which is why libraries ship it as a fallback rather than reimplementing it each time.store.set calls in one event handler run two full notify rounds. React 18 auto-batches the resulting re-renders, but real stores often still queue notifications and flush once in a microtask to avoid redundant snapshot reads.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useSyncExternalStore is the React hook that subscribes a component to a store that lives outside React — a Redux store, a browser API, a plain object with listeners — and returns a consistent snapshot that re-renders the component whenever that store changes. Your job is to build the client shim: the exact userland implementation React ships as use-sync-external-store/shim for versions that predate the built-in hook. You implement it out of the smaller primitives (useState, useEffect, useLayoutEffect) — not by calling React's built-in useSyncExternalStore, which would be cheating the whole point.
The hard part is a one-frame gap. A component reads the store's value while it renders, then subscribes to the store slightly later, in an effect. If the store changes in between, a naive hook that only waits for future notifications never hears about that change — the screen shows a stale value, a bug React calls tearing. The shim closes the gap by re-reading the snapshot at the moment it subscribes.
function useSyncExternalStore<T>(
// Registers a callback the store calls on every change; returns an unsubscribe fn.
subscribe: (onStoreChange: () => void) => () => void,
// Returns the store's current, immutable snapshot. Must be cached for unchanged state.
getSnapshot: () => T,
): T // the current snapshot
// A store outside React: a value, a Set of listeners, and a notify.
const store = createStore(0); // { getSnapshot, subscribe, set }
function Counter() {
const count = useSyncExternalStore(store.subscribe, store.getSnapshot);
return <span>{count}</span>;
}
store.set(1); // every mounted Counter re-renders and shows 1
// The snapshot is compared with Object.is. Notifying with an equal value
// wakes nobody; only a genuinely different snapshot re-renders.
const same = { n: 1 };
const store = createStore(same);
// store.set(same) -> Object.is(same, same) is true -> no re-render
// store.set({ n: 1 }) -> a different reference -> re-render
getSnapshot() right before you subscribe.Object.is, never deep equality. Re-render only when the snapshot's identity changes. An equal snapshot must not re-render.subscribe changes identity. Key the subscription effect on subscribe, and clean up the previous subscription first.getSnapshot must return a cached value for unchanged state. A getSnapshot that builds a fresh object each call is never Object.is-equal to itself and loops forever — that constraint is on the caller, but your hook must not paper over it.getServerSnapshot (used for SSR/hydration) — the core is the client path. It appears in Going further.You will connect a React component to a store that lives outside React, and the entire difficulty is one gap: the store can move between the moment you read it and the moment you start listening.
React components only know how to re-render when React's own state changes. But plenty of state lives outside React — a Redux store, navigator.onLine, a WebSocket, a plain object with a list of listeners. To show that state, a component has to do two things: read the current value, and subscribe so it re-renders on the next change.
The trap is that these happen at two different times. You read the value while the component renders. You subscribe a beat later, inside an effect, after the render has been painted. If the store changes in that sliver of time — and it can, especially when another component's effect writes to the same store during the same commit — a hook that only waits for future notifications will never hear about that one change. It rendered the old value and subscribed too late to catch the new one. The screen is now lying, and no amount of waiting fixes it, because the notification already happened. React has a name for this: tearing.
Think of it as reading a noticeboard and then signing up for alerts. You read the board (the snapshot), then walk over to the sign-up sheet (subscribe). If someone updates the board while you are walking, your alert subscription starts after that update — you will get every future change but you already missed this one, and you are standing there believing the stale thing you read.
The fix is one sentence: when you finally subscribe, re-read the snapshot and compare it against the value you rendered — if it moved, re-render immediately.
Here is the version almost everyone writes first, and it is a good instinct — it is exactly useState plus a subscription:
const { useState, useEffect } = require('react');
function useSyncExternalStore(subscribe, getSnapshot) {
const [snapshot, setSnapshot] = useState(getSnapshot());
useEffect(() => {
return subscribe(() => setSnapshot(getSnapshot()));
}, []);
return snapshot;
}
Read it again, because it looks finished. It returns the right value on mount. When the store notifies, it re-reads and re-renders. It even bails out on an equal value for free, because setSnapshot of an Object.is-equal value is a no-op in React. It passes the basic tests.
And it has three holes, all of which come from the same mistake — it reads the store exactly once and then stops looking. (1) It captures getSnapshot() at mount, so a change in the render-to-subscribe gap is lost forever — the tear. (2) The [] deps mean it never re-subscribes; hand it a new subscribe (a different store) and it keeps listening to the old one. (3) It replays a value captured at mount instead of reading fresh each render. The first hole is the one that matters, and it is invisible: nothing throws, the code looks correct, and the value is simply wrong.
const { useState, useEffect, useLayoutEffect } = require('react');
// Re-read the store and report whether OUR last-seen snapshot still matches it.
// A throwing getSnapshot counts as "changed" so the render can surface the error.
function checkIfSnapshotChanged(inst) {
const latestGetSnapshot = inst.getSnapshot;
const prevValue = inst.value;
try {
const nextValue = latestGetSnapshot();
return !Object.is(prevValue, nextValue);
} catch (error) {
return true;
}
}
function useSyncExternalStore(subscribe, getSnapshot) {
// Read the snapshot during render — every render, synchronously. This is both
// the value we return and the value the effects compare against.
const value = getSnapshot();
// A mutable record of what we last read and how to read it again. React's own
// shim stashes this in a useState slot to save a hook; a ref is the same idea.
// We never use the state it holds — only the fact that setting a fresh {inst}
// forces a re-render.
const [{ inst }, forceUpdate] = useState(() => ({
inst: { value, getSnapshot },
}));
// Keep the record current in the LAYOUT phase, then check for a mutation that
// landed between render and commit.
useLayoutEffect(() => {
inst.value = value;
inst.getSnapshot = getSnapshot;
if (checkIfSnapshotChanged(inst)) forceUpdate({ inst });
}, [subscribe, value, getSnapshot]);
useEffect(() => {
// The store may have moved between render and this effect. Check BEFORE we
// subscribe — otherwise that change is only in the store, never in the UI,
// and no future notification will mention it. This is the tear the shim
// exists to close.
if (checkIfSnapshotChanged(inst)) forceUpdate({ inst });
const handleStoreChange = () => {
// The store notified. Re-render only if OUR snapshot actually moved.
if (checkIfSnapshotChanged(inst)) forceUpdate({ inst });
};
// Subscribe, returning the store's unsubscribe as cleanup. Because the
// effect is keyed on `subscribe`, a new subscribe identity tears down the
// old subscription and builds a fresh one.
return subscribe(handleStoreChange);
}, [subscribe]);
return value;
}
module.exports = { useSyncExternalStore };
Three shifts turn the first attempt into the real thing. First, the snapshot is read during render, every render (const value = getSnapshot()) rather than once at mount — so a plain re-render always shows the live value. Second, a small mutable record, inst, remembers the last value and the latest getSnapshot; checkIfSnapshotChanged is the one function that re-reads and compares. Third — and this is the whole question — that same check runs right before we subscribe, so a change in the gap is caught instead of missed.
Notice forceUpdate({ inst }): we pass a brand-new wrapper object every time. React compares state with Object.is, and a fresh object is never equal to the last one, so this reliably re-renders even though the inst inside is the same mutable record we have been reading all along. The state slot is not storing state — it is a re-render button that also happens to hold our bookkeeping.
Take const store = createStore('a') and one Counter reading it.
value = getSnapshot() is 'a'. The useState initializer stashes inst = { value: 'a', getSnapshot }. The hook returns 'a'.inst.value = 'a', inst.getSnapshot = getSnapshot, then checkIfSnapshotChanged re-reads: 'a' vs 'a', no change.'a' vs 'a', no change. Then subscribe(handleStoreChange) registers the callback and its unsubscribe becomes the cleanup. The store now holds one listener.store.set('b'). The store updates its value and calls every listener. handleStoreChange runs, checkIfSnapshotChanged reads 'b' against inst.value of 'a', they differ, so forceUpdate({ inst }) fires.value = getSnapshot() is now 'b'; the hook returns 'b'. The layout effect sets inst.value = 'b' and the check finds no further change. One change, one re-render, showing 'b'.Now the tear. Suppose that between step 1 and step 3, another component's effect had already called store.set('b'). There is no listener yet, so that notification reaches nobody. But step 3's check before subscribing re-reads the store — 'b' against the 'a' we rendered — sees the difference, and forces a re-render to 'b'. The first attempt has no such check: it subscribes and returns the 'a' it captured at mount, and stays wrong until some unrelated future write happens to nudge it.
getSnapshot that builds a fresh value every call. () => store.items.filter(...) returns a new array each time, is never Object.is-equal to itself, and the shim re-checks, re-renders, re-checks... until React throws Maximum update depth exceeded. This is a crash, not a slow page. Fix: cache the snapshot in the store so unchanged state returns the same reference, and only allocate a new one when the data actually changes.subscribe or getSnapshot as inline arrows. If the caller passes useSyncExternalStore((cb) => store.subscribe(cb), () => store.get()), both are new identities on every render, so the subscription effect (keyed on subscribe) tears down and rebuilds constantly. Fix: the store should expose stable subscribe and getSnapshot references, defined once — never recreated per render.=== or a deep equal instead of Object.is. Object.is differs from === on exactly NaN (equal under Object.is, not under ===) and -0 vs +0. React uses Object.is; matching it avoids a spurious re-render on a NaN snapshot. A deep equal would be far too expensive to run on every notification.subscribe change. With [] deps the hook is glued to the first store forever. Keying the effect on subscribe (and cleaning up first) is what lets the same component point at a different store later.getServerSnapshot for SSR. The real hook takes a third argument used during server render and hydration, where there is no store to subscribe to and getSnapshot might touch browser-only APIs. The shim branches to it when window is absent; React's non-shim build wires it into hydration so the server and first client render agree.useSyncExternalStoreWithSelector. A sibling that adds a selector and an isEqual, keeping a second cache so a component only re-renders when its slice changes. This is what createStore's useSelector and react-redux are built on — they consume the box you just built.useSyncExternalStore cooperates with concurrent rendering to prevent tearing within a single render pass — something userland cannot fully replicate. The shim is the best-effort version for React 17 and below, correct under synchronous rendering, which is why libraries ship it as a fallback rather than reimplementing it each time.store.set calls in one event handler run two full notify rounds. React 18 auto-batches the resulting re-renders, but real stores often still queue notifications and flush once in a microtask to avoid redundant snapshot reads.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.