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

Splitting contexts

Context granularity sets the re-render blast radius: one fat context re-renders every consumer on any change. Split by concern, separate state from setters, and stabilize the value with useMemo — without sliding into provider hell.

RXP Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

You reached for context to kill prop-drilling, and it worked — user, theme, locale, and a dozen setters all flow from one AppContext at the root. Then the profiler lights up: toggling dark mode re-renders the cart, the chat panel re-renders when the user’s name loads, and a settings flip repaints the entire app. Nothing is broken. Everything is just re-rendering for changes it doesn’t care about.

That’s not a context bug. It’s a granularity bug. The shape of your context — how much you cram into one provider, whether you split read-state from writers — directly controls how far a single change ripples. This lesson is about choosing that shape on purpose.

Goal

After this lesson you can explain why a context’s value is its re-render blast radius — every consumer of a provider re-renders when that provider’s value changes identity; split one fat context into focused contexts by concern so a consumer only re-renders for the slice it reads; separate state from its dispatch/setters into two contexts so write-only components don’t re-render on state changes; stabilize each provided value with useMemo so it doesn’t get a new identity every render; and recognize the failure mode — over-splitting into a tower of nested providers — and find the balance.

1

A context’s value is its re-render blast radius: every consumer re-renders when the value’s identity changes. React doesn’t diff inside your context value. When the value passed to a provider is a new reference, every component that calls useContext on it re-renders — regardless of which field it actually reads. So the granularity of your context is the granularity of your re-renders.

// One context, everything in it. Any change → new value object →
// EVERY useContext(AppContext) consumer re-renders, even ones that
// only read `theme` when it was `user` that changed.
const AppContext = createContext<AppState | null>(null);

function AppProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState<"light" | "dark">("light");
  // a fresh object literal every render → identity changes every render
  return (
    <AppContext.Provider value={{ user, setUser, theme, setTheme }}>
      {children}
    </AppContext.Provider>
  );
}

The cart reads user. The header reads theme. Both subscribe to the same context, so both re-render on either change. The blast radius is “everyone”.

2

Split by concern: one context per independently-changing slice. Auth state and theme state change on totally different cadences and are read by different parts of the tree. Give each its own context, and a consumer only subscribes to — and only re-renders for — the slice it actually reads.

const AuthContext = createContext<{ user: User | null; setUser: (u: User | null) => void } | null>(null);
const ThemeContext = createContext<{ theme: Theme; setTheme: (t: Theme) => void } | null>(null);

function AppProviders({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState<Theme>("light");
  return (
    <AuthContext.Provider value={{ user, setUser }}>
      <ThemeContext.Provider value={{ theme, setTheme }}>
        {children}
      </ThemeContext.Provider>
    </AuthContext.Provider>
  );
}

Now useContext(ThemeContext) in the header re-renders on theme changes only; the cart, reading useContext(AuthContext), is untouched when the user toggles dark mode. The blast radius shrank from “everyone” to “everyone who reads this slice”. The axis to split on is what changes together vs. independently, not how the data is shaped on a whiteboard.

3

Separate state from its setters: the writers don’t need the value. A component that only changes the theme — a toggle button — has no reason to re-render when the theme changes. But if theme and setTheme live in the same context, it re-renders anyway. Split read-state into one context and the dispatch/setters into another. Setters are stable for the life of the component, so the dispatch context’s value never changes identity — its consumers re-render essentially never.

const ThemeStateContext = createContext<Theme | null>(null);
const ThemeDispatchContext = createContext<((t: Theme) => void) | null>(null);

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");
  // setTheme from useState is stable across renders, so the dispatch
  // context value is referentially stable → its consumers don't re-render.
  return (
    <ThemeStateContext.Provider value={theme}>
      <ThemeDispatchContext.Provider value={setTheme}>
        {children}
      </ThemeDispatchContext.Provider>
    </ThemeStateContext.Provider>
  );
}

// A toggle button reads ONLY dispatch → never re-renders when theme changes.
function ThemeToggle() {
  const setTheme = useContext(ThemeDispatchContext)!;
  return <button onClick={() => setTheme((prev) => (prev === "dark" ? "light" : "dark"))}>Toggle</button>;
}

This is the same move useReducer + context formalizes: a state context and a dispatch context. dispatch is permanently stable, so write-only consumers cost nothing.

4

Stabilize the provided value with useMemo, or the split buys you nothing. A provider whose value is a fresh object literal gets a new identity every render of the provider, so every consumer re-renders every time the provider re-renders — even if no field actually changed. Splitting contexts doesn’t help if each split still hands down an unstable object. Memoize the value on its real dependencies.

function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  // Without useMemo this object is new every render → identity churn.
  // With it, consumers re-render only when `user` actually changes
  // (setUser is already stable, so it's not in the dep list's intent).
  const value = useMemo(() => ({ user, setUser }), [user]);
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

The state/dispatch split is the cleaner version of this: a bare theme value and a bare stable setTheme need no useMemo at all, because neither is an object literal. Reach for useMemo when the value must be a bundled object (e.g. { user, setUser }); reach for the split when you can avoid the object entirely.

Worked example

One AppContext → focused contexts, with state separated from setters. A dashboard root provides user, theme, and their setters from a single context. The cart reads user, the header reads theme, and a settings toggle only writes the theme.

Before — one context, one unstable value, blast radius “everyone”:

const AppContext = createContext<{
  user: User | null; setUser: (u: User | null) => void;
  theme: Theme; setTheme: (t: Theme) => void;
} | null>(null);

function AppProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState<Theme>("light");
  // new object every render; toggling theme re-renders the cart too
  return (
    <AppContext.Provider value={{ user, setUser, theme, setTheme }}>
      {children}
    </AppContext.Provider>
  );
}

function ThemeToggle() {
  const { setTheme } = useContext(AppContext)!; // re-renders on user changes, for nothing
  return <button onClick={() => setTheme("dark")}>Dark</button>;
}

After — split by concern, then split theme’s state from its dispatch:

const AuthContext = createContext<{ user: User | null; setUser: (u: User | null) => void } | null>(null);
const ThemeStateContext = createContext<Theme | null>(null);
const ThemeDispatchContext = createContext<((t: Theme) => void) | null>(null);

function AppProviders({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState<Theme>("light");
  const auth = useMemo(() => ({ user, setUser }), [user]); // object → memoized
  return (
    <AuthContext.Provider value={auth}>
      <ThemeStateContext.Provider value={theme}>        {/* bare value, no memo */}
        <ThemeDispatchContext.Provider value={setTheme}> {/* stable setter, no memo */}
          {children}
        </ThemeDispatchContext.Provider>
      </ThemeStateContext.Provider>
    </AuthContext.Provider>
  );
}

function ThemeToggle() {
  const setTheme = useContext(ThemeDispatchContext)!; // reads dispatch only → never re-renders on theme/user
  return <button onClick={() => setTheme("dark")}>Dark</button>;
}

Now toggling the theme re-renders only components that read ThemeStateContext. The cart (reads AuthContext) and the toggle (reads ThemeDispatchContext) stay put. Three providers instead of one is the right amount of ceremony here — each boundary buys a real reduction in blast radius.

Why this works

Why does React re-render all consumers on a value change instead of only those reading the changed field? Because context propagation is identity-based, not field-based: the provider holds one value, and useContext subscribes to that value’s reference. React has no idea which fields a given consumer destructures, so it can’t selectively notify. That single design fact is why granularity is a tool you wield manually — splitting contexts is how you tell React “these subscriptions are independent”. External stores (useSyncExternalStore, Zustand) add selector-based subscriptions to dodge this; plain context does not, so you split instead.

Common mistake

The opposite failure of the fat context is provider hell: a dozen single-field contexts nested into a staircase of providers at the root, plus a custom hook and a boilerplate file per slice. You shrank the blast radius to nothing and paid for it in ceremony — every new piece of shared state is now a 40-line ritual, and the tree is unreadable. Split on the axis that matters (does this re-render the wrong things at a frequency you can measure?), not reflexively. Group fields that change together into one context; only separate state from dispatch when there’s a real population of write-only consumers. If you find yourself needing fine-grained selectors across many slices, that’s the signal you’ve outgrown plain context — reach for an external store, not a twentieth provider.

Check yourself
Quiz

You split AppContext into AuthContext and ThemeContext, but each provider still passes value={{ ...fields }} as a fresh object literal. Toggling the theme now re-renders every AuthContext consumer too, even though no auth field changed. What's the cause?

Recap

A context’s value is its re-render blast radius: React notifies every useContext consumer when the provider’s value changes identity, regardless of which field they read. So granularity controls the radius. Three moves shrink it: (1) split by concern — one context per independently-changing slice, so a consumer only re-renders for the slice it reads; (2) separate state from dispatch/setters — write-only components subscribe to the stable setter context and re-render essentially never; (3) stabilize the value with useMemo (or pass bare values / stable setters) so a provider re-render alone doesn’t churn identity and defeat the split. The failure mode is provider hell — over-splitting into a staircase of single-field providers, trading blast radius for ceremony and unreadable boilerplate. Split on measured pressure: group what changes together, separate state from dispatch only where real write-only consumers exist, and when you genuinely need fine-grained selectors across many slices, that’s the signal to move to an external store rather than add one more provider.

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.