Sharing values with useContext
React's Context API for sharing cross-cutting values like the user, theme, and locale across the tree, and how to keep it from re-rendering everything.
Picture the SaaS app you’re building. A signed-in user, the theme they picked, a locale that decides whether a date reads 06/03 or 3 June, a map of feature flags gating which buttons exist. A <Sidebar> fifteen layers deep needs the user; a <DateLabel> buried in a table needs the locale. These are values the whole tree reaches for, yet no single component owns them.
In “Four homes for state” you felt the pain of moving such 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 left one tool unnamed, the one that makes a value available to a whole subtree at once. That tool is context.
Context comes with a catch. The naive version, one provider holding everything and read anywhere, carries a re-render cost that stays invisible until the app grows large and the interface feels sluggish. This lesson gives you context for propagating cross-cutting values, and the three disciplines that keep a careless one from re-rendering the whole tree.
What context is for
Section titled “What context is for”Start with the model the rest of the lesson rests on: context is propagation, not a store. A context takes one value you hand it and broadcasts it to every descendant that asks. A provider sits high in the tree, and any component below reads the value directly, skipping every layer in between. It removes the drilling, but you still manage the value yourself.
Hold on to “propagation, not a store,” because the pitfall and all three fixes follow from it. A store lets 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.
So context earns its weight for one kind of value: cross-cutting infrastructure that many regions read and that changes rarely. The boundary is where the mistakes happen, so be clear about both sides.
Context is for:
- The authenticated user and active organization, read everywhere, changed only on login or an org switch.
- The theme and locale, read by anything that renders text or color, changed when the user flips a setting.
- The feature-flag map and router instance, ambient facts every region consults.
Every part of the app reads these, 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. Drilling a prop two or three components down is cheaper than it feels; just pass it, or restructure so the consumer is passed in as
children, the composition move from “Children and compound”. Reaching for context 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.
- 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 scroll frame. You’re about to learn why context handles this poorly.
Before any syntax, make 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.
Creating and reading a context
Section titled “Creating and reading a context”A context has three pieces: create it, provide a value, read it. We’ll walk through them with a running example of the current user.
You create a context once, at module scope, with createContext:
const UserContext = createContext<User | null>(null);The type tells you how context behaves. The value is User | null and the default is null because a component that calls useContext outside any provider gets no error, just that default. The type has to allow for a reader with no provider above it, and the honest default for “no user” is null. You’ll turn that gap to your advantage in a moment.
To make a value available to a subtree, render the provider with a value. In React 19 the provider is the context:
<UserContext value={currentUser}> <App /></UserContext>Every component inside <App />, at any depth, can now read currentUser from UserContext. You’ll also see <UserContext.Provider value={currentUser}>, the older, longer form that most library code and tutorials still show. Recognize it, but write the short form, which is the current default.
Reading is one call:
const user = useContext(UserContext);The value flows in from the nearest provider above the component. With no provider, you get the createContext default, here null.
Reading raw like this has a cost. useContext(UserContext) hands back User | null, so every call site has to handle the null: user?.name, user?.email, a guard on every read. That’s a lot of ceremony for a case that, in a correctly wired app, never happens, since the user context is always set inside the app shell.
The fix is the pattern this section is built around: a fail-fast consumer hook. Wrap useContext once instead of calling it directly:
const useCurrentUser = () => { const user = useContext(UserContext); if (user === null) { throw new Error('useCurrentUser must be used inside <UserProvider>'); } return user;};Now the rest of the codebase calls useCurrentUser() and gets back a plain, non-nullable User. The null is gone from every call site, and a component rendered with no provider fails loudly and immediately, with a message that points at the problem, instead of surfacing three components later as “cannot read property name of null”. The use prefix 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 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 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 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.
Each context owns its provider, and you compose them at the root by nesting: <AuthProvider><ThemeProvider><LocaleProvider>…. The pyramid gets deep, but each provider is cheap, and you can flatten it later with a small composeProviders helper if it bothers you.
Why one value change re-renders every consumer
Section titled “Why one value change re-renders every consumer”The rule that makes context costly: when a provider’s value changes, every component reading that context re-renders, regardless of which field it uses. Context subscription is all-or-nothing. There is no “subscribe to just value.theme”; reading the context subscribes you to every change of the whole value.
You already know the machinery behind this. In “What triggers a render” you saw that React decides whether to re-render by comparing the new value to the old with Object.is : equal means bail out, not-equal means render. Context applies that same comparison to the provided value. When the new value is Object.is-different from last render, React re-renders every consumer . It can’t be more precise: it knows the reference changed, not which fields each consumer read.
Here is where that costs you. Suppose you 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 reads user. On a three-box page you’d never notice. In a real product with hundreds of components reading the context, one theme toggle re-renders the entire subscribed tree, and the user feels the lag.
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.
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 re-rendered anyway.
The wrong explanation for why 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?
<UserBadge> never touches theme, and only the components that read the field that changed get re-rendered.theme differs from last time.value with Object.is; a rebuilt { user, theme, locale } is a new reference, so every consumer is notified. There’s no per-field diff and no “only readers of the changed field re-render” rule — <UserBadge> re-renders even though user never changed.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 split the context so unrelated changes stop colliding, stop bundling read with write, and keep the reference stable.
Mitigation 1: split the context by concern
Section titled “Mitigation 1: split the context by concern”The foundational fix is 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 reads.
Trace the theme toggle again. ThemeContext’s value changes; UserContext and LocaleContext hold the same references as before. React notifies the theme consumers and only the theme consumers. <UserBadge>, which reads 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 context.
The same tree, now with a toggle between the two designs. Switch to “split contexts,” run the identical buttons, and watch the blast radius collapse from the whole tree to the one box that changed.
Make this your default posture, not a fix you reach for once profiling flags a problem. Split first, before the storm is ever measurable. An extra context costs almost nothing: a createContext call and one more provider in the root nest. Retrofitting a split onto an already-bundled context means touching every consumer that reads it. Cheap up front, 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, because those values change together and the same components read them. Don’t shatter a concern into a context per field. The test: 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, flagged back in “useReducer”.
Take a 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 in one context, since they’re the same concern:
<NotificationsContext value={{ state, dispatch }}>But look at who reads it. 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. With one bundled context, every state change rebuilds { state, dispatch }, so that button re-renders on every notification even though nothing it shows has changed. Bundle 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:
NotificationsStateContextholdsstate, the queue. Components that display notifications read it and re-render when the queue changes.NotificationsDispatchContextholdsdispatch, the action sender. Components that only act read it.
The split is free because dispatch is reference-stable: React hands you the same function on every render. So the dispatch context’s value never changes, and a component reading only dispatch subscribes to something that never updates. The “Clear all” button stops re-rendering on notification activity; the display components re-render only when the queue actually changes.
Here are the two shapes side by side. The first bundles, 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. Rebuilding { state, dispatch } on every state change re-renders every consumer, including action-only components like a “Clear all” button that never reads state.
export const NotificationsProvider = ({ children }: { children: ReactNode }) => { const [state, dispatch] = useReducer(notificationsReducer, []); return ( <NotificationsStateContext value={state}> <NotificationsDispatchContext value={dispatch}> {children} </NotificationsDispatchContext> </NotificationsStateContext> );};Splits read from write. Action-only consumers read dispatch from its own context, and because dispatch is reference-stable, that context’s value never changes, so those consumers stop re-rendering. Display consumers read state and re-render only when the queue changes.
Each context gets its own fail-fast consumer hook, as in the previous section: useNotifications() for the state and useNotificationsDispatch() for the sender, each throwing a clear error at the call site when its provider is missing. 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.”
Mitigation 3: keep the provider value’s reference stable
Section titled “Mitigation 3: keep the provider value’s reference stable”The last cause is the easiest to miss: pure object identity. It’s the same rule from “What triggers a render”, surfacing through context. Two object literals with byte-for-byte identical contents are different values to Object.is, because they’re different objects in memory. Now look at this provider:
<UserContext value={{ user, role }}>That literal is built fresh on every render of the provider’s parent. Even when user and role are unchanged, each render produces a new { user, role }, a new reference. Object.is sees two different objects, reports a change, and every consumer re-renders. This is the storm with nobody touching state: the parent re-renders for any reason, a new value object appears, and the whole subscribed subtree re-renders with it.
The manual fix makes the mechanism visible. 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 changes, so 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 from the last section, skip this and pass it directly.
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. When the provider is pure, it stabilizes the value object for you, so the plain literal and the useMemo version compile to the same behavior. So write the provider plainly and let the compiler hold 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 plus 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.
export const UserProvider = ({ user, role, children }: UserProviderProps) => { const value = useMemo(() => ({ user, role }), [user, role]); return <UserContext value={value}>{children}</UserContext>;};Stable by hand. useMemo reuses the same object until user or role changes. This makes the mechanism visible and is the documented fallback, but it’s ceremony the compiler usually removes, not code you write by default.
export const UserProvider = ({ user, role, children }: UserProviderProps) => { return <UserContext value={{ user, role }}>{children}</UserContext>;};Plain, and stable anyway. With the React Compiler on and a pure provider, the compiler memoizes this value object for you, so it compiles to the same behavior as the useMemo tab. This is what you actually write, with the memo implicit.
One 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. A blog post may claim 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 a 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 cares about has changed. Hold the rule, and the three fixes follow from it instead of being a list to memorize.
Context and Server Components
Section titled “Context and Server Components”React Context is a client-runtime mechanism, so Server Components can’t call useContext: they have no hooks. A Server Component can render a provider but never consume one.
Next.js apps handle this with one pattern, worth recognizing now. 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 into the Client Components that read context below.
Directoryapp/
- layout.tsx Server Component, mounts
<Providers>high in the tree Directory_components/
- providers.tsx
'use client', holds the context providers
- providers.tsx
Directorydashboard/
- page.tsx Server Component, fetches data, passes it down as props
- sidebar.tsx Client Component, reads context with
useContext
- layout.tsx Server Component, mounts
Looking ahead, there’s a second way to read a context, use(Context), which can be called conditionally, even after an early return. That’s the subject of the upcoming use() lesson. For now, useContext is the tool.
When to graduate off context
Section titled “When to graduate off context”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 is the signal to reach for an external store like Zustand or Jotai, which lets each component subscribe to just the slice it reads. You will meet those stores later; for now, know the upgrade path exists and what triggers it.
The other shared-value tools, 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 → pass the prop, or compose with
children.
The line to carry out of this lesson: context is propagation, not a store. Split it by concern, keep its value reference stable, and when the value turns into high-churn application state, graduate to a store. The React docs below are the canonical references for the pattern.
The official guide to createContext, providers, and useContext, including when not to reach for context.
The canonical state/dispatch-split pattern: a reducer shared through two contexts.
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?
External resources
Section titled “External resources”Alex Sidorenko animates exactly this lesson's mechanism — why a provider value change re-renders every consumer, and how reference stability contains it.
Nadia Makarevich's deep dive into what triggers re-renders, including the context anti-patterns and the split-and-memoize fixes.
The official write-up of the automatic memoization that stabilizes your provider value, so you write the plain literal and skip the manual useMemo.
The store you graduate to when a shared value becomes high-churn application state — each component subscribes to just the slice it reads.