10LOC
Reactadvanced

Context selector pattern to stop re-rendering every consumer

Published December 15, 2026

import { createContext, useContext, useSyncExternalStore } from "react";

type State = { count: number; user: { name: string } };
type Listener = () => void;

const createStore = (initialState: State) => {
  let state = initialState;
  const listeners = new Set<Listener>();
  const getState = () => state;
  const setState = (patch: Partial<State>) => {
    state = { ...state, ...patch };
    listeners.forEach((listener) => listener());
  };
  const subscribe = (listener: Listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };
  return { getState, setState, subscribe };
};

export const StoreContext = createContext(createStore({ count: 0, user: { name: "" } }));

export const useStoreSelector = <Slice,>(select: (state: State) => Slice): Slice => {
  const store = useContext(StoreContext);
  return useSyncExternalStore(store.subscribe, () => select(store.getState()));
};

What

A "context selector" is a hook built on useSyncExternalStore that lets a component subscribe to just the slice of shared state it reads, so it only re-renders when that slice actually changes — not on every update to the store.

Why it matters

Plain useContext re-renders every consumer whenever the provided value changes, full stop — there's no way to say "I only care about user.name, not count." Splitting one big context into many small ones works, but only if your state naturally divides that way; a lot of app state doesn't. React doesn't ship a useContextSelector, and libraries like use-context-selector exist specifically to fill that gap using the same trick shown here: store the state outside React and subscribe to it with useSyncExternalStore, whose whole contract is "only re-render this component if the selected snapshot changed."

How it works

  • createStore holds state in a closure, not in React state — mutations happen through setState, and every subscriber in listeners is notified after each change.
  • subscribe and getState match the shape useSyncExternalStore expects: a way to register a change listener, and a way to read the current snapshot.
  • StoreContext provides a single store instance app-wide (no Provider needed unless you want a scoped, per-subtree store instead).
  • useStoreSelector(select) is the payoff: it calls useSyncExternalStore(store.subscribe, () => select(store.getState())), so React only re-renders the calling component when select's return value actually changes between notifications.

Gotchas

  • select must return a value that's cheap to compute and ideally stable (Object.is-equal) when nothing relevant changed — returning a fresh object or array literal every call defeats the whole point, since every reference looks "different." This is also why the snippet keeps lineWaiver: true: the store and the selector hook only prove the point together, and trimming either half leaves a non-functional example.
  • This pattern trades context's simplicity for manual store plumbing; reach for it only once you've confirmed re-renders from a shared context are an actual, measured problem.
  • setState here does a shallow merge ({ ...state, ...patch }) — nested objects still need to be replaced wholesale to be seen as changed by reference-based comparisons elsewhere in the app.

Related

  • useSyncExternalStore — the primitive this pattern is built on; see it applied to real browser state (not app state) in the useSyncExternalStore post on this blog.
  • Zustand or Redux with selectors — production libraries that implement this exact idea with far more ergonomics (equality functions, devtools, middleware) than this minimal version.