30% offEnding soon
useContextSelectorLoading saved progress…

useContextSelector

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.

Signature

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;
};

Examples

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);

Notes

  • The context carries the store, not the state. 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.
  • Two Providers are two stores. Each Provider scopes an independent state to its subtree, and the nearest one wins. A consumer with no Provider above it throws.
  • The subscribe-and-select engine is not new. createStore + useSelector built it against a plain store; here the twist is putting that store inside a context. Out of scope: reducers, middleware, persistence.