Skip to content
Chapter 25Lesson 5

useContext without the re-render storm

React's Context API, how to share cross-cutting values like the user, theme, and locale across your component tree, and how to keep it from re-rendering everything.

Picture the SaaS app you’re building. There’s a signed-in user. There’s a color theme the user picked. There’s a locale that decides whether dates read 06/03 or 3 June. There’s a map of feature flags gating which buttons exist. None of these live in one place: a <Sidebar> fifteen layers deep needs the user, a <DateLabel> buried in a table needs the locale, a <ThemeToggle> in the header needs the theme. These are values the whole tree reaches for, and no single component owns them.

In “The four homes for state” you felt the pain of getting a value from where it lives to where it’s read: threading it as a prop through every component in between, each one accepting a user it never touches just to hand it one layer down. That chapter named the pain, prop-drilling, and deliberately left one question open: isn’t there a tool that just makes a value available to a whole subtree? There is. It’s called context, and this lesson is where you learn it.

Context comes with a catch, which is what the title of this lesson points to. The naive version, where you bundle everything into one provider and read it anywhere, carries a performance problem that stays invisible until the app is large and the interface feels sluggish. This lesson covers both sides. It gives you context as the tool for propagating cross-cutting values, and it explains the re-render cost that makes a careless context expensive. By the end you’ll be able to decide when context is the right tool, write one that fails loudly when misused, explain why a bundled context re-renders the whole tree, and apply the three disciplines that keep it fast.

Start with the mental model that the rest of the lesson rests on: context is propagation, not a store. A context doesn’t manage state, slice it, or do anything clever with it. It takes one value you hand it and broadcasts that value to every descendant that asks. A provider sits high in the tree, and any component below it can read the value directly, skipping every layer in between. That’s the entire job. It removes the drilling, but it does not remove the need to manage the value yourself.

Hold on to that “propagation, not a store” idea, because the pitfall and all three fixes follow from it. A store would let you subscribe to one slice of a value. Context can’t, because it broadcasts the whole thing. We’ll see what that costs in a moment.

Because context is propagation, it earns its weight for a specific kind of value: cross-cutting infrastructure that many regions read and that changes rarely. It’s worth being clear about what that excludes, because the boundary is where the mistakes happen.

Context is for:

  • The authenticated user and the active organization, read everywhere, changed only on login or an org switch.
  • The theme and the locale, read by anything that renders text or color, changed when the user flips a setting.
  • The feature-flag map and the router instance, ambient facts every region consults.

These share a shape: every part of the app reads them, nobody wants to drill them, and they sit still most of the time.

Context is not for:

  • A shortcut around three layers of prop-drilling. If a value travels two or three components, just pass the prop, or restructure so the consumer is passed in as children, the composition move from “Children and compound components”. Context is for cross-cutting concerns, not for skipping a couple of intermediate components. Reaching for it the moment a prop annoys you is the most common misuse.
  • Server state, data you fetch from your backend. That belongs to Server Components or TanStack Query, which you’ll meet later.
  • Form state, the text in a field as the user types. The form component owns that locally.
  • High-frequency updates, a value that changes on every keystroke or every scroll frame. You’re about to learn why context handles this poorly.

That first item under “not for” is the misuse you’ll be most tempted by, so it’s worth dwelling on. Moving state into context is not the cure for prop-drilling that’s only a few layers deep; context is for genuinely cross-cutting concerns. Drilling a prop two components down is cheaper than it feels, and a context you reach for too early costs more than it looks like it will.

Before any syntax, try making that judgment yourself. Drag each value into the bucket where it belongs.

Decide whether each value is cross-cutting infrastructure that earns a context, or something that belongs elsewhere. Drag each item into the bucket it belongs to, then press Check.

Belongs in context Cross-cutting infrastructure, read everywhere, changes rarely
Does not belong in context Local, server, form, or high-frequency state
The signed-in account
The app’s color theme
The user’s current locale
Text in a search box before the user submits
Whether one dropdown is open or closed
The team’s saved invoices loaded from the server
The position of a slider while the user is dragging it

The mechanism has three pieces: create the context, provide a value, read it. We’ll walk through them using the running example of a current user.

You create a context once, at module scope, with createContext:

const UserContext = createContext<User | null>(null);

Read the type carefully, because it tells you how context behaves. The value is User | null, and the default you pass is null. Why allow null at all, when in a running app there’s always a logged-in user? Because a component that calls useContext outside any provider doesn’t get an error; it silently gets the default. The type has to allow for a reader rendered with no provider above it, and the honest default for “no user available” is null. In a moment you’ll turn that gap to your advantage.

To make a value available to a subtree, you render the provider with a value. In React 19 the provider is the context:

<UserContext value={currentUser}>
<App />
</UserContext>

Every component rendered inside <App />, at any depth, can now read currentUser from UserContext. You may also see <UserContext.Provider value={currentUser}>, the older, longer form, which is still valid and still what most library code and tutorials show. Recognize it when you see it, but write the short form. (The .Provider form is on a deprecation path, with an automatic codemod to migrate, so the bare <Context value> is the 2026 default.)

Reading is one call:

const user = useContext(UserContext);

The value flows in from the nearest provider above this component. If there’s no provider, you get the createContext default, which here is null.

Reading context raw like this has a downside. useContext(UserContext) hands back User | null, so every call site has to deal with the null: user?.name, user?.email, a guard on every read. That’s a lot of ceremony to handle a case that, in a correctly-wired app, never happens, since your user context is always set inside the app shell. You don’t want a thousand optional chains defending against a state that is really a programming error.

The fix is the pattern this section is built around: a fail-fast consumer hook. Instead of letting components call useContext directly, you wrap it once:

const useCurrentUser = () => {
const user = useContext(UserContext);
if (user === null) {
throw new Error('useCurrentUser must be used inside <UserProvider>');
}
return user;
};

Now the rest of your codebase imports useCurrentUser() and gets back a plain, non-nullable User. The null is gone from every call site. The case you were defending against, a component rendered with no provider above it, now fails loudly and immediately, with a message that tells you exactly what’s wrong. Compare that to a silent null that surfaces three components later as “cannot read property name of null”. This is the same instinct behind the requireUser-style helpers you’ll write on the server: for something that must be present, don’t return a maybe-missing value, require it and throw a clear error when it’s absent. The use prefix isn’t decoration, either. It marks this as a hook, a contract React’s tooling enforces, which is the subject of this chapter’s final lesson on the rules of hooks.

Here’s the canonical shape, the one every later section builds on: the context, its provider, and the consumer hook, together in one small file.

import { createContext, useContext } from 'react';
import type { ReactNode } from 'react';
const UserContext = createContext<User | null>(null);
export const UserProvider = ({ user, children }: { user: User; children: ReactNode }) => {
return <UserContext value={user}>{children}</UserContext>;
};
export const useCurrentUser = () => {
const user = useContext(UserContext);
if (user === null) {
throw new Error('useCurrentUser must be used inside <UserProvider>');
}
return user;
};

The context is declared once, at module scope. The default is null and the type is User | null because a reader rendered outside any provider falls back to that default, so the type has to allow for it.

import { createContext, useContext } from 'react';
import type { ReactNode } from 'react';
const UserContext = createContext<User | null>(null);
export const UserProvider = ({ user, children }: { user: User; children: ReactNode }) => {
return <UserContext value={user}>{children}</UserContext>;
};
export const useCurrentUser = () => {
const user = useContext(UserContext);
if (user === null) {
throw new Error('useCurrentUser must be used inside <UserProvider>');
}
return user;
};

The provider component wraps children and sets the value with the bare React 19 form. Its user prop is typed User, non-null, because the app shell only renders this once it has a real signed-in user.

import { createContext, useContext } from 'react';
import type { ReactNode } from 'react';
const UserContext = createContext<User | null>(null);
export const UserProvider = ({ user, children }: { user: User; children: ReactNode }) => {
return <UserContext value={user}>{children}</UserContext>;
};
export const useCurrentUser = () => {
const user = useContext(UserContext);
if (user === null) {
throw new Error('useCurrentUser must be used inside <UserProvider>');
}
return user;
};

The fail-fast hook reads the raw context. If it’s null, meaning no provider above, it throws a precise error instead of handing back a null that breaks three components later.

import { createContext, useContext } from 'react';
import type { ReactNode } from 'react';
const UserContext = createContext<User | null>(null);
export const UserProvider = ({ user, children }: { user: User; children: ReactNode }) => {
return <UserContext value={user}>{children}</UserContext>;
};
export const useCurrentUser = () => {
const user = useContext(UserContext);
if (user === null) {
throw new Error('useCurrentUser must be used inside <UserProvider>');
}
return user;
};

Past the guard, the value is narrowed to User. Every call site imports useCurrentUser and reads a non-nullable User, with no optional chaining anywhere.

1 / 1

One more practical note. Each context owns its own provider component, and you compose them at the root by nesting: <AuthProvider><ThemeProvider><LocaleProvider>…. The nesting can get deep and a little ugly, but each provider is cheap, and you can flatten the pyramid later with a small composeProviders helper if it bothers you. It’s worth knowing the option exists, but not worth building today.

Why one value change re-renders every consumer

Section titled “Why one value change re-renders every consumer”

Here is the rule that makes context costly, stated plainly: when a provider’s value changes, every component that reads that context re-renders, regardless of which field it actually uses. Context subscription is all-or-nothing. There is no “subscribe to just value.theme.” Once you read the context, you’ve subscribed to every change of the whole value.

This isn’t arbitrary, and you already know the machinery behind it. In “What triggers a render” you learned that React decides whether to re-render by comparing the new value to the old one with Object.is : equal means bail out, not-equal means render. Context is that same comparison, applied to the provided value. When the provider’s value is Object.is-different from last render, React walks the tree and re-renders every consumer of that context. It can’t be more precise, because it has no idea which fields each consumer read. It only knows the value reference changed.

Here is where that costs you. Suppose you took the “obvious” path and bundled everything into one context:

const value = { user, theme, locale };
<AppContext value={value}>
<ThemeButton />
<UserBadge />
<LocaleLabel />
</AppContext>

The user toggles the theme. theme changes, so you build a new value object, so its reference changes, so every consumer re-renders, including <UserBadge />, which only ever reads user and doesn’t care about the theme at all. On a three-box page you’d never notice. In a real product with hundreds of components reading the context, a single theme toggle re-renders the entire subscribed tree, and the user feels the lag.

Don’t take the rule on faith. The widget below is a small tree: an App with a ThemeButton, a UserBadge, and a LocaleLabel, all reading one bundled context. Each button changes one field. Click any of them and watch which boxes light up.

One bundled context

Every action lights every box. Toggle the theme and UserBadge flashes; rename the user and LocaleLabel flashes. None of them read the field that changed, and they all re-rendered anyway. That’s the all-or-nothing rule made visible.

It’s worth being precise about why the boxes light up, because the wrong explanation is an easy one to believe.

A single AppContext provides { user, theme, locale }. A <UserBadge> reads only user from it. The user toggles the theme, which rebuilds the context value. What happens to <UserBadge>, and why?

It re-renders. The provider handed out a brand-new value object, and a context wakes up every reader whenever its value’s reference changes.
Nothing — <UserBadge> never touches theme, and only the components that read the field that changed get re-rendered.
It re-renders, because any state update anywhere in the app re-renders the whole component tree.
Nothing — React diffs the context value field by field, and only theme differs from last time.

So the storm has two independent causes, and naming them sets up the fixes. One is too much in one context: bundling unrelated concerns so a change to any of them notifies all of them. The other is a fresh value reference every render: handing out a new object even when nothing inside it changed. The next three sections take these apart. The first splits the context so unrelated changes stop colliding, the second stops bundling read with write, and the third keeps the reference stable.

Mitigation 1: split the context by concern

Section titled “Mitigation 1: split the context by concern”

The first fix is the foundational one, and it’s structural: one context per cohesive concern. Instead of a single AppContext carrying { user, theme, locale }, declare three contexts that change independently, UserContext, ThemeContext, and LocaleContext, each with its own provider. A component calls useContext only on the contexts it actually reads.

Now trace the theme toggle again. ThemeContext’s value changes, while UserContext’s and LocaleContext’s values are untouched, the same references as before. React notifies the theme consumers and only the theme consumers. <UserBadge>, reading UserContext, never hears about it and doesn’t re-render. The storm is gone, not because you optimized anything, but because you stopped wiring unrelated things to the same wire.

Here is the same tree, now with a toggle between the two designs. Switch to “split contexts” and run the identical buttons, and watch the blast radius collapse from the whole tree to the one box that changed.

Bundled vs split contexts

Make this your default posture, not a fix you reach for once profiling flags a problem. Split first, before the storm is ever measurable. The cost of an extra context is near zero: a createContext call and one more provider in the root nest. Retrofitting a split onto a context that’s already bundled, by contrast, means touching every consumer that reads it. It’s cheap to do up front and expensive to undo later, and that asymmetry is the whole argument.

One guardrail so you don’t overcorrect: split by concern, not by individual variable. Everything theme-related, the current theme, the setter, and the available themes, belongs in one ThemeContext together, because they change as a unit and the same components read them. Don’t shatter a concern into a context-per-field, which is ceremony with no payoff. The unit of cohesion is the concern, and the test is simple: things that change together and are read together stay together.

Mitigation 2: separate state from dispatch

Section titled “Mitigation 2: separate state from dispatch”

The cohesion rule has one complication, the one “useReducer when transitions multiply” warned you was coming.

Take a real shared concern: a notifications queue of toasts that get added, dismissed, and cleared. It’s backed by a useReducer (the natural fit for a queue with several action types), and you want to share it across the app through context. The obvious move is to put the reducer’s state and dispatch into one context together, since they’re the same concern, after all:

<NotificationsContext value={{ state, dispatch }}>

But look at who reads this. Some components display notifications: they read state and should re-render when the queue changes. Others only act: a “Clear all” button calls dispatch({ type: 'clearAll' }) and never reads state at all. With one bundled context, every state change rebuilds { state, dispatch }, so that button re-renders on every notification that comes and goes, even though it displays nothing that ever changes. You bundled the read with the write, and the write-only consumers pay for every read.

The fix is to split state from dispatch into two contexts:

  • NotificationsStateContext holds state, the queue. Components that display notifications read this and re-render when the queue changes, exactly as they should.
  • NotificationsDispatchContext holds dispatch, the action sender. Components that only act read this.

Here’s why the split is free, and it’s the payoff from the reducer lesson: dispatch is reference-stable across renders. React guarantees you the same dispatch function on every render, forever. So the dispatch context’s value never changes, its reference is constant, and a component that reads only dispatch subscribes to something that never updates. The “Clear all” button now never re-renders on notification activity. The display components re-render only when the actual queue state changes. Read and write are cleanly separated.

Here are the two shapes side by side. The first bundles, and the second splits.

export const NotificationsProvider = ({ children }: { children: ReactNode }) => {
const [state, dispatch] = useReducer(notificationsReducer, []);
return (
<NotificationsContext value={{ state, dispatch }}>
{children}
</NotificationsContext>
);
};

Bundles read and write. Every consumer re-renders on any dispatch, including action-only components like a “Clear all” button that never reads state, because the { state, dispatch } object is rebuilt on every state change.

Each context gets its own fail-fast consumer hook, exactly as in the previous section: useNotifications() for the state and useNotificationsDispatch() for the sender. A missing provider then throws a clear error at the call site instead of a silent null. This pairing, a reducer shared via context and split into state and dispatch contexts, is a canonical React pattern. The official docs call it “Scaling Up with Reducer and Context,” and it’s worth recognizing by name when you see it in a codebase.

Mitigation 3: keep the provider value reference-stable

Section titled “Mitigation 3: keep the provider value reference-stable”

The last cause of the storm is the easiest to miss, and it’s pure object identity: the same reference rule from “What triggers a render”, now showing up through context. Recall the rule from there: two object literals with byte-for-byte identical contents are different values to Object.is, because they’re different objects in memory. Look closely at this provider:

<UserContext value={{ user, role }}>

That object literal in the JSX is constructed fresh on every render of the provider’s parent. Even when user and role haven’t changed at all, each render produces a brand-new { user, role }, a new reference. Object.is compares it to last render’s object, sees two different objects, says “changed,” and every consumer re-renders. This is the storm with nobody touching any state: the parent re-renders for any reason, a new value object is created, and the whole subscribed subtree re-renders along with it.

To see the mechanism, here’s the manual fix. Wrap the value so its reference stays stable as long as its contents do:

const value = useMemo(() => ({ user, role }), [user, role]);
<UserContext value={value}>

useMemo returns the same object across renders until user or role actually changes, so the reference is stable and consumers re-render only on a real change. (When the value is already a stable reference, such as a single primitive or the reducer state object from the last section, you don’t need this at all; pass it directly.)

Here is how this actually changes what you write. You don’t write that useMemo in 2026. The project ships with the React Compiler on, so manual useMemo and useCallback aren’t your default reach: the compiler auto-memoizes for you. When the provider component is pure, the compiler stabilizes the value object automatically, so the plain literal and the useMemo version compile to the same behavior. The ceremony drops away. The senior move, then, is to write the provider plainly and let the compiler keep the value stable. Reach for a manual useMemo only as a fallback, when React DevTools shows the compiler skipped this component because of an impure body, an opt-out, or a shape it couldn’t infer. The useMemo above is a teaching shape that makes the identity mechanism visible, along with the documented escape hatch, not code you sprinkle by hand.

Here are all three, in the order that builds up to what you actually write.

export const UserProvider = ({ user, role, children }: UserProviderProps) => {
return <UserContext value={{ user, role }}>{children}</UserContext>;
};

A fresh object every render. The { user, role } literal is a new reference on every parent render, so Object.is always sees a change and every consumer re-renders, even when user and role are identical.

One precise caveat, so you don’t over-credit the compiler. It stabilizes the provider’s value object, fixing this identity trap and nothing more. It does not change the all-consumers-re-render-on-value-change rule from earlier: when the value genuinely changes, every consumer still re-renders, and the compiler does not narrow a consumer’s subscription to only the field it reads. You may run into a blog post claiming it does. It doesn’t, and that claim isn’t in the React docs. This is exactly why splitting the context (mitigation 1) stays necessary: the compiler handles identity, but cohesion is still your job.

Step back and the three mitigations collapse into one idea. Split the context so unrelated things don’t share the same wire, split state from dispatch so read and write aren’t bundled, and keep the value reference stable so unchanged contents don’t hand out a fresh object. All three serve a single rule: a context re-renders its consumers exactly when its value reference changes. Each one makes that reference change only when something a consumer actually cares about has changed. Once you hold the rule, the three fixes follow from it instead of being a list to memorize.

There’s one constraint to know before you wire context into a real Next.js app. React Context is a client-runtime mechanism, and Server Components can’t call useContext, because they have no hooks. A Server Component can render a provider, but it cannot consume one.

The pattern Next.js apps use to handle this is worth recognizing now, even though you’ll learn the boundary mechanics properly when we get to the App Router. You put your context providers inside a single 'use client' component, conventionally app/_components/providers.tsx, and mount it high in the root layout. Server Components above that island fetch data and pass it as props down into the Client Components that read context below. The server gathers, and the client provides and consumes.

  • Directoryapp/
    • layout.tsx Server Component, mounts <Providers> high in the tree
    • Directory_components/
      • providers.tsx 'use client', holds the context providers
    • Directorydashboard/
      • page.tsx Server Component, fetches data, passes it down as props
      • sidebar.tsx Client Component, reads context with useContext

One thing to flag looking ahead: there’s a second way to read a context, use(Context), that does everything useContext does and adds one capability useContext lacks. It can be called conditionally, even after an early return. That’s the subject of the upcoming use() lesson in this chapter. For now, useContext is the tool.

Now that you understand the cost model, you can place context precisely, and, just as important, know when to graduate off it. Context is for low-frequency cross-cutting infrastructure. The moment the shared value becomes application state, mutated from many places, sliced into many independent subscribers, and updated often, context’s all-consumers-re-render model stops helping and starts working against you. That’s the signal to reach for an external store like Zustand or Jotai, which is built to let each component subscribe to just the slice it reads. You’ll meet those stores later in the course. For now, just know the upgrade path exists and what triggers it.

Here are the other tools that aren’t context, one line each, so you reach for context deliberately rather than by reflex:

  • Server state, data from your backend → Server Components or TanStack Query.
  • URL state, filters, sort, the current tab → the URL itself, via nuqs.
  • A value that travels two or three layers → just pass the prop, or compose with children.

And here is the line to carry out of this lesson: context is propagation, not a store. Reach for it for infrastructure, split it by concern, and keep its value reference stable. When the value turns into high-churn application state, graduate to a store. The React docs below are the canonical references for the pattern, and the second one is the official write-up of the reducer-and-context split from mitigation 2.

One last check, then. The mistake to watch for is reaching for context when the shape of the value has quietly outgrown it.

You’re building a multi-step customer wizard. Dozens of fields live in one shared object, the value changes on nearly every keystroke, and dozens of small inputs each read one or two fields — each should re-render only when its own fields change. Which tool fits, and why?

Reach for an external store like Zustand: the wizard object is high-churn application state, and a store lets each input subscribe to just the field it reads, so a keystroke re-renders only the inputs that field touches.
One context holding the whole wizard object — the data is shared across the entire wizard, and sharing data down a subtree is exactly the job context exists for.
One context, but declare a separate context per field, so editing a field re-renders only that field’s consumers and nothing else.
A single context, leaning on the React Compiler — it stabilizes the provider value and narrows each consumer to re-render only on the fields it actually reads.