open atlas
↑ Back to track
React patterns, senior RXP · 06 · 01

Context for low-frequency state

Context broadcasts to every consumer whenever its value changes, so it fits low-frequency, widely-read state like theme, auth user, and locale — and turns fast-changing state into a tree-wide re-render storm.

RXP Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

Context looks like the obvious fix the moment a prop has to travel through five components that don’t care about it. You wrap a Provider near the root, call useContext at the leaf, and the drilling is gone. It feels like a free win, so it gets reached for again — for the form’s draft values, for the cursor position, for the currently-hovered cell.

That second wave is where teams get burned. Context is not a faster prop; it is a broadcast channel. When its value changes, every consumer in the subtree re-renders, and React gives you no way to subscribe to “just the part I read”. The pattern that erased prop-drilling for your theme will, used for a fast-changing value, re-render a third of your tree on every keystroke. This lesson is about knowing which of those two situations you’re in.

Goal

After this lesson you can state the rule that decides context fit — context suits low-frequency, widely-read state — and explain it from the mechanism: a context value change re-renders all consumers unconditionally. You can build a ThemeContext done right, with a stable value that distant components read cheaply. And you can recognize the failure mode — high-frequency state (field values, mouse position, animation frames) in context causing a re-render storm — and name where that state should live instead.

1

A context value change re-renders every consumer — there is no partial subscription. When a Provider’s value is a new reference, React marks every component that called useContext for that context as needing to re-render. It does not matter that a consumer only reads value.theme and the thing that changed was value.locale; the unit of change is the whole value, and the unit of notification is “all consumers”. React.memo does not save you: a memoized component still re-renders when a context it consumes updates, because context is a separate channel from props.

const AppContext = createContext<{ theme: Theme; locale: string } | null>(null);

// reads only `theme`, but re-renders whenever `locale` changes too —
// because the value reference changed, and memo can't block a context update
const ThemeBadge = memo(function ThemeBadge() {
  const { theme } = useContext(AppContext)!;
  return <span data-theme={theme} />;
});

This is the single fact the whole pattern hangs on: context is broadcast, all-or-nothing, to every consumer.

2

Because change is broadcast, context fits state that changes rarely and is read widely. Theme, the authenticated user, locale, feature flags — these flip seconds or minutes apart (or once per session) and are needed in dozens of scattered places. A broadcast on every change is cheap when changes are rare, and prop-drilling them would be miserable. That is the exact shape context was designed for: low-frequency, widely-read. The re-render cost is real but paid almost never; the prop-drilling cost is avoided constantly.

// good fit: changes on a user click, read all over the app
const ThemeContext = createContext<ThemeApi | null>(null);

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme must be used within <ThemeProvider>");
  return ctx;
}

If your candidate state changes many times per second, it is the opposite shape — and context is the wrong tool, no matter how widely it’s read.

3

Done right: keep the provider’s value stable, so consumers re-render only on real changes. The cost of context is paid whenever the value reference changes — so an unstable value (a fresh object literal every render) re-renders all consumers on every parent render, even when nothing meaningful changed. Memoize the value, and keep setters stable so they never force a value change. A clean ThemeContext flips the value only when the theme actually flips.

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");

  // toggle is stable; useState's setter identity is already stable
  const toggle = useCallback(() => setTheme((t) => (t === "light" ? "dark" : "light")), []);

  // value reference changes ONLY when `theme` changes → consumers re-render only then
  const value = useMemo(() => ({ theme, toggle }), [theme, toggle]);

  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

Without the useMemo, { theme, toggle } is a new object on every ThemeProvider render, so any unrelated state change near the root would broadcast to every consumer for nothing.

4

Failure mode: put high-frequency state in context and every consumer becomes a re-render storm. Field values that change on each keystroke, mouse position that changes on each mousemove, an animation frame that changes 60 times a second — route any of these through a context and every consumer re-renders at that frequency, including ones that don’t even read the changing field. This is the classic context anti-pattern: a “form context” holding live input values, where typing one character re-renders every field, the submit button, and the validation summary.

// ANTI-PATTERN: live values in context → typing re-renders the whole form tree
const FormContext = createContext<{ values: Record<string, string>; set: Fn } | null>(null);

function Field({ name }: { name: string }) {
  const { values, set } = useContext(FormContext)!; // re-renders on EVERY keystroke,
  return <input value={values[name]} onChange={(e) => set(name, e.target.value)} />; // in any field
}

The fix is not to memoize harder — it’s to stop broadcasting fast state. Keep high-frequency state local (each field owns its own useState), lift only what’s truly shared, or use a store with selector subscriptions (useSyncExternalStore, Zustand, Jotai) so a component re-renders only when its slice changes. Context broadcasts; fast state needs targeted subscription.

Worked example

A theme done right vs. a form done wrong — same API, opposite outcomes. Start with the theme. The value flips only on a deliberate user action and is read by distant, unrelated components:

// ThemeProvider near the root; value is memoized so it changes only on theme flip
function Page() {
  return (
    <ThemeProvider>
      <Header />     {/* reads theme for the logo */}
      <Sidebar />    {/* reads theme for icons */}
      <Editor />     {/* reads theme for the canvas */}
    </ThemeProvider>
  );
}

function Header() {
  const { theme, toggle } = useTheme();
  return <button onClick={toggle}>{theme === "light" ? "🌙" : "☀️"}</button>;
}

Toggling re-renders Header, Sidebar, Editor — exactly once, on a rare click. That is context working as designed: the broadcast is the feature, because all three genuinely need the new theme.

Now the mistake: the same shape applied to live form values. A FormProvider holds { values, setValue }; every Field consumes it. Typing one letter in the email field changes values, which changes the context value, which re-renders every field, the submit button, and the validation summary — on each keystroke.

// BEFORE (storm): one keystroke re-renders the whole form
<FormProvider>
  <Field name="email" /> <Field name="password" /> {/* all re-render per keystroke */}
  <SubmitButton /> <ValidationSummary />
</FormProvider>

// AFTER (calm): each field owns its own state; context holds only stable handles
function Field({ name }: { name: string }) {
  const [value, setValue] = useState("");           // local, fast state stays here
  const { register } = useForm();                   // context value is stable: never changes on keystroke
  return <input value={value} onChange={(e) => { setValue(e.target.value); register(name, e.target.value); }} />;
}

Same createContext / useContext API, but the AFTER version keeps the fast-changing value out of the broadcast channel. The field re-renders on its own keystrokes (local state, unavoidable and cheap); the rest of the form no longer does. The pattern didn’t change — the frequency of the value in it did, and that is the entire decision.

Why this works

Why doesn’t React.memo fix a chatty context? Because memo only compares props. Context is delivered through a separate path: a consumer subscribes to the context, and when the provider’s value changes, React schedules that consumer to re-render regardless of whether its props changed. So a memo-wrapped component sitting in a chatty context still re-renders on every value change. Memo and context are orthogonal — memo guards the props door, context comes in through a different one. The only real levers are: change the value less often (stabilize it), or switch to a subscription model that lets each consumer read just its slice.

Common mistake

A subtle version of the storm: splitting state and dispatch into one context to “share everything”, then putting fast state beside slow state in the same value. Even if components only need the slow part, the fast part churning the value re-renders them all. The standard remedy is to split contexts by change frequency — e.g. a stable DispatchContext (setters, never changes) separate from a StateContext, and within state, separate a rarely-changing slice from a fast one. Components subscribe only to the channel whose cadence they can afford. One mega-context carrying both cadences is how a “clean” refactor silently reintroduces the storm.

Check yourself
Quiz

A teammate moves live form-field values into a FormContext so any component can read them, and wraps each Field in React.memo to keep things fast. Typing in one field still re-renders every field. Why?

Recap

Context is a broadcast channel: when a provider’s value reference changes, every consumer re-renders, with no partial subscription and no rescue from React.memo (which only compares props). That mechanism is exactly why context fits low-frequency, widely-read state — theme, auth user, locale, feature flags — where changes are rare and reads are scattered, so the broadcast is paid almost never. Build such a context with a stable, memoized value so consumers re-render only on real changes. The failure mode is the mirror image: route high-frequency state (field values, mouse position, animation frames) through context and every keystroke or frame becomes a tree-wide re-render storm. The fix isn’t more memo — it’s keeping fast state local, lifting only what’s shared, or using a selector-based store so each consumer re-renders only on its own slice. Match the channel to the frequency: broadcast for the rare-and-global, targeted subscription for the fast.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.