A React context re-renders every component that reads it whenever its value changes — even a component that only reads one field. useContextSelector fixes that: a consumer names the slice it wants and re-renders only when that slice changes. useContext has no selector; the granularity is the whole value, so the moment a context holds { user, theme, cart }, bumping cart re-renders the component that reads only theme.
Build createContextStore(initialState). It returns a Provider, a useContextSelector(selector) for reading a slice, and a useSetState() for writing. This is one of the most-asked React performance questions, and the fix is counter-intuitive — see the solution for why splitting contexts and React.memo are the wrong tools.
function createContextStore<S>(initialState: S): {
// creates ONE store for its lifetime; may override the initial state
Provider: (props: { children: ReactNode; initialState?: S }) => ReactElement;
// reads a slice; re-renders the caller ONLY when that slice changes.
// throws if used outside the Provider. isEqual defaults to Object.is.
useContextSelector<T>(selector: (s: S) => T, isEqual?: (a: T, b: T) => boolean): T;
// returns the store's setState; merges the patch, and its identity is stable
useSetState(): (patch: Partial<S> | ((prev: S) => Partial<S>)) => void;
};
Two consumers read two slices of one store. A write to one wakes only its reader:
const { Provider, useContextSelector, useSetState } = createContextStore({
user: { name: 'ada' },
cart: { count: 0 },
});
function Header() {
const name = useContextSelector((s) => s.user.name);
return <h1>{name}</h1>;
}
function CartBadge() {
const count = useContextSelector((s) => s.cart.count);
return <span>{count}</span>;
}
// somewhere in the tree: setState({ cart: { count: 3 } })
// CartBadge re-renders. Header does not — its slice did not move.
The second argument is for selectors that build their answer:
// filter() returns a new array every call, so this is never Object.is-equal
// to its own last answer — the default equality re-renders it into a loop.
useContextSelector((s) => s.items.filter((i) => i.done));
// Tell the store what "unchanged" means for this value, and it settles.
useContextSelector((s) => s.items.filter((i) => i.done), shallowEqual);
Provider builds one store on first render and puts that stable handle in the context. Because the context value never changes, no consumer re-renders from the context — each subscribes to the store for its own slice.useContextSelector gates by Object.is. A consumer wakes only when its selected value changes. The second argument replaces the comparison for values that are rebuilt each call — you do not write shallowEqual, the tests pass one in.useSetState merges. setState({ a: 1 }) leaves the other keys alone, and the updater form (prev) => partial computes the patch from the current state.Provider scopes an independent state to its subtree, and the nearest one wins. A consumer with no Provider above it throws.You will stop putting state in a context and start putting a store there, so the context never changes and no consumer ever re-renders because of it.
A React context is a broadcast, not a subscription. When a provider's value changes, React re-renders every component that called useContext for it — there is no way to say only wake me for the theme field. The granularity is the whole value.
That is fine when the context holds one thing. It becomes a performance bug the moment it holds two. Put { user, theme, cart } in one provider, and a component that reads only theme re-renders every time the cart count ticks — it asked for nothing that moved, and it woke anyway. Nothing throws. Nothing is stale. The app just does a pile of work nobody asked for, and you find it in a profiler six months later, on a page that got slow for no reason.
One provider, three readers, and a write that touches only one slice. The context wakes all three, because a context change does not carry what changed.
The naive fixes are worth naming, because both are the first thing people reach for and both are wrong here. Splitting into many contexts works but does not compose — every new slice is another provider, and the nesting explodes. Memoising the consumer does not work at all.
So you build the obvious thing: keep the state in the provider, put the state object into the context value, and let the selector pick a slice out of it during render.
function createContextStore(initialState) {
const Ctx = createContext(null);
function Provider({ children }) {
const [state, setState] = useState(initialState);
// a new value object every time state changes
return <Ctx.Provider value={{ state, setState }}>{children}</Ctx.Provider>;
}
function useContextSelector(selector) {
return selector(useContext(Ctx).state); // reads, but does not gate
}
// ...
}
Read it again, because it looks finished. It returns the right slice and it updates. Measured against this question's suite it passes ten of the seventeen tests — reads, writes, two independent providers, nested providers, unmounting, all green. And it has not done the thing: setState builds a new value object every time, the context sees a changed value, and it re-renders every consumer — exactly as before.
Now the part that surprises people. You reach for React.memo, because that is how you stop a component re-rendering. It does nothing:
React.memo compares the props a parent passes down. A context change is not a prop — React re-renders context consumers directly, underneath the props path — so the memo has nothing to compare and nothing to stop. Measured: a React.memo'd component that reads only theme and takes no props at all re-renders every single time cart changes.
The fix is to stop putting the state in the context. Put a store there — an object with getState, setState and subscribe — built once and never replaced. Its identity never changes, so the context value never changes, so the context never wakes anyone. Each consumer reaches through the context to the store and subscribes to its own slice.
const React = require('react');
const { createContext, useContext, useRef, useSyncExternalStore } = React;
// A selector may legitimately select `undefined` (a key not set yet), so
// `undefined` cannot double as "nothing cached". A private symbol never collides
// with a value a selector could return.
const EMPTY = Symbol('empty');
function createContextStore(initialState) {
// The store goes in the context, and it is the one thing in there that never
// changes. Its identity is fixed for a Provider's whole life, so the context
// value never changes, so React never re-renders a consumer *because of the
// context*. Consumers subscribe to the store for their slice instead.
const StoreContext = createContext(null);
function makeStore(state) {
const listeners = new Set();
return {
getState: () => state,
setState: (patch) => {
const partial = typeof patch === 'function' ? patch(state) : patch;
// Merge into a NEW object so a selector can tell changed from unchanged
// by reference; never mutate.
state = { ...state, ...partial };
// Snapshot the Set: a listener that unsubscribes mid-round must not
// disturb the loop.
for (const listener of [...listeners]) listener();
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
function Provider({ children, initialState: override }) {
// Build the store ONCE, on first render, and keep the same handle forever.
// useRef, not useState — we never want a new store and never a re-render
// from here. This is the line that makes the context value stable.
const storeRef = useRef(null);
if (storeRef.current === null) {
storeRef.current = makeStore(override !== undefined ? override : initialState);
}
return React.createElement(StoreContext.Provider, { value: storeRef.current }, children);
}
function useStore() {
const store = useContext(StoreContext);
if (store === null) {
throw new Error(
'useContextSelector must be used inside its <Provider>. Wrap the tree ' +
'in the Provider returned by createContextStore.',
);
}
return store;
}
function useContextSelector(selector, isEqual = Object.is) {
const store = useStore();
// One cache per component, because every component selects something else.
const cache = useRef(EMPTY);
const getSnapshot = () => {
const next = selector(store.getState());
const prev = cache.current;
// React re-renders when the snapshot changes by Object.is, so "stay put"
// has one spelling: return the PREVIOUS reference. isEqual only decides
// whether we may. (This selector/equality engine is the one from the
// useSelector question; the new idea here is where the store lives.)
if (prev !== EMPTY && isEqual(prev, next)) return prev;
cache.current = next;
return next;
};
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
function useSetState() {
// The store's setState never changes identity, so a component that only
// writes never re-renders when the state changes.
return useStore().setState;
}
return { Provider, useContextSelector, useSetState };
}
module.exports = { createContextStore };
The subscribe-and-select half — the useRef cache, the Object.is gate, the isEqual argument — is the engine from createStore + useSelector, and that question is where to read why it works and why a fresh-object selector needs an equality function. The new idea is one line: the context value is storeRef.current, a handle built once and never replaced. Everything follows from that.
This is the inversion the whole question turns on. In the naive version the context transports state, so it changes on every write and drags every consumer with it. Here the context transports a handle — a fixed reference to a store that lives beside React — and the state travels through the store's own subscribe, which is a real subscription with per-consumer granularity. The context has become a delivery mechanism for where the store is, not for what the state is. A component calling useSetState proves the point: it reads the handle and never the state, so it never re-renders when the state changes.
There is a second payoff that a module-level store cannot give you. Because the store is created inside the Provider, every <Provider> owns a fresh, independent store: two subtrees hold two separate states, a nested provider shadows an outer one, and the store is garbage-collected with the tree. State that is scoped to a subtree, not to the whole module.
createContextStore({ user: { name: 'ada' }, cart: { count: 0 } }), with a Header selecting s.user.name and a CartBadge selecting s.cart.count, both under one Provider.
Provider mounts. storeRef.current is null, so it builds the store once and hands value={store} to the context. That reference will never change again.Header renders. useContextSelector reads the store from context, then useSyncExternalStore calls store.subscribe and getSnapshot. The selector returns 'ada'; the cache was EMPTY, so it stores and returns 'ada'. CartBadge does the same for 0, in its own cache.setState({ cart: { count: 3 } }). The store merges into a brand-new state object and fires its listeners. The context value did not change — it is still the same store handle — so React does not touch the context path at all.Header's getSnapshot runs the selector on the new state: 'ada'. Object.is('ada', 'ada') is true, so it returns the cached reference, React sees no change, and Header never re-renders. CartBadge's getSnapshot returns 3; Object.is(0, 3) is false, so it re-renders and shows 3.Object.is passes for both, and nothing re-renders — even though the state object changed by reference twice. The gate is per-consumer, and it is the only gate you need.This exact hook exists in userland as dai-shi's use-context-selector, and it reaches the same destination by a different road. It patches createContext so the context value is a fixed container of refs plus its own listener Set, and its useContextSelector(context, selector) keeps a useReducer that bails via Object.is on the selected value — driven by the scheduler package, not useSyncExternalStore. Same core move as ours (a stable thing in the context value, a separate subscription, an Object.is gate on the selection), built before useSyncExternalStore existed. It takes no isEqual argument, so a fresh-object selector re-renders on every update rather than crashing.
The honest headline: React looked at this exact problem and shipped useSyncExternalStore instead of a context selector.
| dai-shi/use-context-selector | this store | |
|---|---|---|
| in the context value | refs + a private listener Set | a store handle |
| subscription | useReducer + scheduler priority | useSyncExternalStore |
| gate | Object.is on the selection | Object.is, plus an isEqual hook |
| fresh-object selector | re-renders every update | crashes (needs isEqual) |
The useContextSelector RFC — proposing exactly this as a built-in — has sat open and unmerged since 2019, with the React team pointing at useSyncExternalStore and, later, the compiler. So the pattern in this solution is not a workaround for a missing feature; it is the feature, assembled from the primitive React chose to ship. Lifting state into an external store and reading it with useSyncExternalStore is React's own answer to do not re-render every consumer of a context.
value={{ ...state }} is a new object on every write, so the context changes and every consumer wakes. Fix: put a stable store handle in the value and subscribe to it — the whole point of this question.React.memo. A context change re-renders consumers underneath the props path, so memo has nothing to compare. It cannot stop a context-driven re-render; only removing the state from the context can.const store = makeStore(initialState) in the Provider body makes a new store — and a new context value — every render, which is the bug with extra steps. Fix: useRef (or a lazy useState) so it is built once.(s) => s.items.filter(...) returns a fresh array every call, is never Object.is-equal to itself, and loops until React throws Maximum update depth exceeded. Fix: pass shallowEqual as the second argument, or select the pieces with two useContextSelector calls. This is the useSelector trap, inherited whole.initialState after mount. The store is built once, so a new initialState prop on a later render is ignored by design — remounting the Provider (a fresh key) is how you reset a subtree's store.dispatch. Swap setState(patch) for dispatch(action) and a reducer, and this becomes a scoped Mini Redux with per-subtree state — which is roughly what a scoped Redux store plus useSelector is.use-context-selector or a scoped store library (zustand's createStore + a context) ship the concurrent-safe version of this, tested against React's internals — build it once to understand it, then use theirs.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A React context re-renders every component that reads it whenever its value changes — even a component that only reads one field. useContextSelector fixes that: a consumer names the slice it wants and re-renders only when that slice changes. useContext has no selector; the granularity is the whole value, so the moment a context holds { user, theme, cart }, bumping cart re-renders the component that reads only theme.
Build createContextStore(initialState). It returns a Provider, a useContextSelector(selector) for reading a slice, and a useSetState() for writing. This is one of the most-asked React performance questions, and the fix is counter-intuitive — see the solution for why splitting contexts and React.memo are the wrong tools.
function createContextStore<S>(initialState: S): {
// creates ONE store for its lifetime; may override the initial state
Provider: (props: { children: ReactNode; initialState?: S }) => ReactElement;
// reads a slice; re-renders the caller ONLY when that slice changes.
// throws if used outside the Provider. isEqual defaults to Object.is.
useContextSelector<T>(selector: (s: S) => T, isEqual?: (a: T, b: T) => boolean): T;
// returns the store's setState; merges the patch, and its identity is stable
useSetState(): (patch: Partial<S> | ((prev: S) => Partial<S>)) => void;
};
Two consumers read two slices of one store. A write to one wakes only its reader:
const { Provider, useContextSelector, useSetState } = createContextStore({
user: { name: 'ada' },
cart: { count: 0 },
});
function Header() {
const name = useContextSelector((s) => s.user.name);
return <h1>{name}</h1>;
}
function CartBadge() {
const count = useContextSelector((s) => s.cart.count);
return <span>{count}</span>;
}
// somewhere in the tree: setState({ cart: { count: 3 } })
// CartBadge re-renders. Header does not — its slice did not move.
The second argument is for selectors that build their answer:
// filter() returns a new array every call, so this is never Object.is-equal
// to its own last answer — the default equality re-renders it into a loop.
useContextSelector((s) => s.items.filter((i) => i.done));
// Tell the store what "unchanged" means for this value, and it settles.
useContextSelector((s) => s.items.filter((i) => i.done), shallowEqual);
Provider builds one store on first render and puts that stable handle in the context. Because the context value never changes, no consumer re-renders from the context — each subscribes to the store for its own slice.useContextSelector gates by Object.is. A consumer wakes only when its selected value changes. The second argument replaces the comparison for values that are rebuilt each call — you do not write shallowEqual, the tests pass one in.useSetState merges. setState({ a: 1 }) leaves the other keys alone, and the updater form (prev) => partial computes the patch from the current state.Provider scopes an independent state to its subtree, and the nearest one wins. A consumer with no Provider above it throws.You will stop putting state in a context and start putting a store there, so the context never changes and no consumer ever re-renders because of it.
A React context is a broadcast, not a subscription. When a provider's value changes, React re-renders every component that called useContext for it — there is no way to say only wake me for the theme field. The granularity is the whole value.
That is fine when the context holds one thing. It becomes a performance bug the moment it holds two. Put { user, theme, cart } in one provider, and a component that reads only theme re-renders every time the cart count ticks — it asked for nothing that moved, and it woke anyway. Nothing throws. Nothing is stale. The app just does a pile of work nobody asked for, and you find it in a profiler six months later, on a page that got slow for no reason.
One provider, three readers, and a write that touches only one slice. The context wakes all three, because a context change does not carry what changed.
The naive fixes are worth naming, because both are the first thing people reach for and both are wrong here. Splitting into many contexts works but does not compose — every new slice is another provider, and the nesting explodes. Memoising the consumer does not work at all.
So you build the obvious thing: keep the state in the provider, put the state object into the context value, and let the selector pick a slice out of it during render.
function createContextStore(initialState) {
const Ctx = createContext(null);
function Provider({ children }) {
const [state, setState] = useState(initialState);
// a new value object every time state changes
return <Ctx.Provider value={{ state, setState }}>{children}</Ctx.Provider>;
}
function useContextSelector(selector) {
return selector(useContext(Ctx).state); // reads, but does not gate
}
// ...
}
Read it again, because it looks finished. It returns the right slice and it updates. Measured against this question's suite it passes ten of the seventeen tests — reads, writes, two independent providers, nested providers, unmounting, all green. And it has not done the thing: setState builds a new value object every time, the context sees a changed value, and it re-renders every consumer — exactly as before.
Now the part that surprises people. You reach for React.memo, because that is how you stop a component re-rendering. It does nothing:
React.memo compares the props a parent passes down. A context change is not a prop — React re-renders context consumers directly, underneath the props path — so the memo has nothing to compare and nothing to stop. Measured: a React.memo'd component that reads only theme and takes no props at all re-renders every single time cart changes.
The fix is to stop putting the state in the context. Put a store there — an object with getState, setState and subscribe — built once and never replaced. Its identity never changes, so the context value never changes, so the context never wakes anyone. Each consumer reaches through the context to the store and subscribes to its own slice.
const React = require('react');
const { createContext, useContext, useRef, useSyncExternalStore } = React;
// A selector may legitimately select `undefined` (a key not set yet), so
// `undefined` cannot double as "nothing cached". A private symbol never collides
// with a value a selector could return.
const EMPTY = Symbol('empty');
function createContextStore(initialState) {
// The store goes in the context, and it is the one thing in there that never
// changes. Its identity is fixed for a Provider's whole life, so the context
// value never changes, so React never re-renders a consumer *because of the
// context*. Consumers subscribe to the store for their slice instead.
const StoreContext = createContext(null);
function makeStore(state) {
const listeners = new Set();
return {
getState: () => state,
setState: (patch) => {
const partial = typeof patch === 'function' ? patch(state) : patch;
// Merge into a NEW object so a selector can tell changed from unchanged
// by reference; never mutate.
state = { ...state, ...partial };
// Snapshot the Set: a listener that unsubscribes mid-round must not
// disturb the loop.
for (const listener of [...listeners]) listener();
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
function Provider({ children, initialState: override }) {
// Build the store ONCE, on first render, and keep the same handle forever.
// useRef, not useState — we never want a new store and never a re-render
// from here. This is the line that makes the context value stable.
const storeRef = useRef(null);
if (storeRef.current === null) {
storeRef.current = makeStore(override !== undefined ? override : initialState);
}
return React.createElement(StoreContext.Provider, { value: storeRef.current }, children);
}
function useStore() {
const store = useContext(StoreContext);
if (store === null) {
throw new Error(
'useContextSelector must be used inside its <Provider>. Wrap the tree ' +
'in the Provider returned by createContextStore.',
);
}
return store;
}
function useContextSelector(selector, isEqual = Object.is) {
const store = useStore();
// One cache per component, because every component selects something else.
const cache = useRef(EMPTY);
const getSnapshot = () => {
const next = selector(store.getState());
const prev = cache.current;
// React re-renders when the snapshot changes by Object.is, so "stay put"
// has one spelling: return the PREVIOUS reference. isEqual only decides
// whether we may. (This selector/equality engine is the one from the
// useSelector question; the new idea here is where the store lives.)
if (prev !== EMPTY && isEqual(prev, next)) return prev;
cache.current = next;
return next;
};
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
function useSetState() {
// The store's setState never changes identity, so a component that only
// writes never re-renders when the state changes.
return useStore().setState;
}
return { Provider, useContextSelector, useSetState };
}
module.exports = { createContextStore };
The subscribe-and-select half — the useRef cache, the Object.is gate, the isEqual argument — is the engine from createStore + useSelector, and that question is where to read why it works and why a fresh-object selector needs an equality function. The new idea is one line: the context value is storeRef.current, a handle built once and never replaced. Everything follows from that.
This is the inversion the whole question turns on. In the naive version the context transports state, so it changes on every write and drags every consumer with it. Here the context transports a handle — a fixed reference to a store that lives beside React — and the state travels through the store's own subscribe, which is a real subscription with per-consumer granularity. The context has become a delivery mechanism for where the store is, not for what the state is. A component calling useSetState proves the point: it reads the handle and never the state, so it never re-renders when the state changes.
There is a second payoff that a module-level store cannot give you. Because the store is created inside the Provider, every <Provider> owns a fresh, independent store: two subtrees hold two separate states, a nested provider shadows an outer one, and the store is garbage-collected with the tree. State that is scoped to a subtree, not to the whole module.
createContextStore({ user: { name: 'ada' }, cart: { count: 0 } }), with a Header selecting s.user.name and a CartBadge selecting s.cart.count, both under one Provider.
Provider mounts. storeRef.current is null, so it builds the store once and hands value={store} to the context. That reference will never change again.Header renders. useContextSelector reads the store from context, then useSyncExternalStore calls store.subscribe and getSnapshot. The selector returns 'ada'; the cache was EMPTY, so it stores and returns 'ada'. CartBadge does the same for 0, in its own cache.setState({ cart: { count: 3 } }). The store merges into a brand-new state object and fires its listeners. The context value did not change — it is still the same store handle — so React does not touch the context path at all.Header's getSnapshot runs the selector on the new state: 'ada'. Object.is('ada', 'ada') is true, so it returns the cached reference, React sees no change, and Header never re-renders. CartBadge's getSnapshot returns 3; Object.is(0, 3) is false, so it re-renders and shows 3.Object.is passes for both, and nothing re-renders — even though the state object changed by reference twice. The gate is per-consumer, and it is the only gate you need.This exact hook exists in userland as dai-shi's use-context-selector, and it reaches the same destination by a different road. It patches createContext so the context value is a fixed container of refs plus its own listener Set, and its useContextSelector(context, selector) keeps a useReducer that bails via Object.is on the selected value — driven by the scheduler package, not useSyncExternalStore. Same core move as ours (a stable thing in the context value, a separate subscription, an Object.is gate on the selection), built before useSyncExternalStore existed. It takes no isEqual argument, so a fresh-object selector re-renders on every update rather than crashing.
The honest headline: React looked at this exact problem and shipped useSyncExternalStore instead of a context selector.
| dai-shi/use-context-selector | this store | |
|---|---|---|
| in the context value | refs + a private listener Set | a store handle |
| subscription | useReducer + scheduler priority | useSyncExternalStore |
| gate | Object.is on the selection | Object.is, plus an isEqual hook |
| fresh-object selector | re-renders every update | crashes (needs isEqual) |
The useContextSelector RFC — proposing exactly this as a built-in — has sat open and unmerged since 2019, with the React team pointing at useSyncExternalStore and, later, the compiler. So the pattern in this solution is not a workaround for a missing feature; it is the feature, assembled from the primitive React chose to ship. Lifting state into an external store and reading it with useSyncExternalStore is React's own answer to do not re-render every consumer of a context.
value={{ ...state }} is a new object on every write, so the context changes and every consumer wakes. Fix: put a stable store handle in the value and subscribe to it — the whole point of this question.React.memo. A context change re-renders consumers underneath the props path, so memo has nothing to compare. It cannot stop a context-driven re-render; only removing the state from the context can.const store = makeStore(initialState) in the Provider body makes a new store — and a new context value — every render, which is the bug with extra steps. Fix: useRef (or a lazy useState) so it is built once.(s) => s.items.filter(...) returns a fresh array every call, is never Object.is-equal to itself, and loops until React throws Maximum update depth exceeded. Fix: pass shallowEqual as the second argument, or select the pieces with two useContextSelector calls. This is the useSelector trap, inherited whole.initialState after mount. The store is built once, so a new initialState prop on a later render is ignored by design — remounting the Provider (a fresh key) is how you reset a subtree's store.dispatch. Swap setState(patch) for dispatch(action) and a reducer, and this becomes a scoped Mini Redux with per-subtree state — which is roughly what a scoped Redux store plus useSelector is.use-context-selector or a scoped store library (zustand's createStore + a context) ship the concurrent-safe version of this, tested against React's internals — build it once to understand it, then use theirs.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.