Context
Context: teleport a value down a subtree
Section titled “Context: teleport a value down a subtree”Context lets a parent make a value available to every component below it, without passing props through the layers in between. You create a context, wrap a subtree in a provider, and any descendant reads it with useContext.
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext("light");
function App() { const [theme, setTheme] = useState("dark"); return ( // React 19: you can render <ThemeContext> directly as the provider. // (The classic <ThemeContext.Provider> still works too.) <ThemeContext value={theme}> <Page /> </ThemeContext> );}
function Button() { const theme = useContext(ThemeContext); // reads the nearest provider's value return <button className={theme}>Click</button>;}Button gets theme no matter how deep it is, and no intermediate component has to forward it. This is the fix for prop drilling when composition can’t reach the consumers.
React 19: <Context> is the provider
Section titled “React 19: <Context> is the provider”Before React 19, the only way to provide a context value was <ThemeContext.Provider value={...}>. React 19 lets you render the context object itself as a component — <ThemeContext value={...}> — and it behaves as the provider. The createContext reference says it plainly:
Starting in React 19, you can render
<SomeContext>as a provider. In older versions of React, use<SomeContext.Provider>.
.Provider isn’t removed — both forms provide the same context — but new code should reach for the shorter form first.
import { createContext, useContext, useState } from 'react';
export const ThemeContext = createContext('light');
export function ThemeApp() { const [theme, setTheme] = useState('dark'); return ( // React 19: <ThemeContext> itself is the provider — no `.Provider`. <ThemeContext value={theme}> <ThemeLabel /> <button onClick={() => setTheme((t) => (t === 'dark' ? 'light' : 'dark'))}> toggle </button> </ThemeContext> );}
function ThemeLabel() { const theme = useContext(ThemeContext); return <span>{theme}</span>;}import { render, screen, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { ThemeApp } from '../components/ThemeApp';
afterEach(cleanup);
test('React 19 renders <Context> directly as its own provider — no .Provider needed', () => { render(<ThemeApp />); expect(screen.getByText('dark')).toBeInTheDocument();});✓ React 19 renders <Context> directly as its own provider — no .Provider needed
Test Files 1 passed (1) Tests 1 passed (1)use(Context): reading context conditionally
Section titled “use(Context): reading context conditionally”useContext must be called unconditionally at a component’s top level, like every Hook. use reads a context the same way, but the use reference is explicit that it drops that restriction:
Unlike
useContext,usecan be called within loops and conditional statements likeif.
That matters for a component that only needs the context value along one branch — with useContext you’d read it unconditionally (and either not use it, or restructure the component); with use you read it exactly where it’s needed.
import { createContext, use } from 'react';
export const LabelContext = createContext('light');
export function ConditionalThemeReader({ show }: { show: boolean }) { let label = 'hidden'; if (show) { // Unlike useContext, `use` is allowed inside a conditional block. label = use(LabelContext); } return <span>{label}</span>;}import { render, screen, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { ConditionalThemeReader, LabelContext } from '../components/ConditionalThemeReader';
afterEach(cleanup);
test('use(Context) reads the value when the conditional branch runs', () => { render( <LabelContext value="dark"> <ConditionalThemeReader show={true} /> </LabelContext>, ); expect(screen.getByText('dark')).toBeInTheDocument();});
test('use(Context) is simply never called when the branch does not run', () => { render( <LabelContext value="dark"> <ConditionalThemeReader show={false} /> </LabelContext>, ); expect(screen.getByText('hidden')).toBeInTheDocument();});✓ use(Context) reads the value when the conditional branch runs✓ use(Context) is simply never called when the branch does not run
Test Files 1 passed (1) Tests 2 passed (2)use still searches upward for the nearest provider above the calling component, exactly like useContext — it just isn’t pinned to the top of the function.
When Context fits — and when it hurts
Section titled “When Context fits — and when it hurts”Context is designed for low-frequency, broadly-shared values: theme, current user/auth, locale, a design-system config. Things that many components read but that change rarely.
The catch is performance: when a provider’s value changes, every consumer re-renders — and React can’t skip them with memo, because they subscribe to the context directly. The useContext reference states this as a caveat:
React automatically re-renders all the children that use a particular context starting from the provider that receives a different
value. […] Skipping re-renders withmemodoes not prevent the children receiving fresh context values.
flowchart TB prov["Provider value changes"] --> c1["Consumer A re-renders"] prov --> c2["Consumer B re-renders"] prov --> c3["Consumer C re-renders"] note["memo cannot stop a context re-render"] -.-> c1
So putting fast-changing state (e.g. a value that updates on every keystroke or animation frame) in a single wide context is a performance trap — it re-renders the whole subtree constantly.
Proof: a new provider value re-renders a memoized consumer every time
Section titled “Proof: a new provider value re-renders a memoized consumer every time”The trap is subtle because it doesn’t require the data to change — a brand-new object literal with the same fields is enough, because React compares the provider’s value by reference (Object.is), not by deep equality.
import { createContext, useContext, useState, useMemo, memo } from 'react';
type Ctx = { label: string };export const LabelContext = createContext<Ctx>({ label: '' });
export const Consumer = memo(function Consumer({ onRender }: { onRender: () => void }) { const { label } = useContext(LabelContext); onRender(); return <span>{label}</span>;});
export function UnstableValueApp({ onChildRender }: { onChildRender: () => void }) { const [tick, setTick] = useState(0); // ❌ A brand-new object every render — even though `label` never changes. return ( <LabelContext value={{ label: 'fixed' }}> <Consumer onRender={onChildRender} /> <button onClick={() => setTick((t) => t + 1)}>{tick}</button> </LabelContext> );}
export function StableValueApp({ onChildRender }: { onChildRender: () => void }) { const [tick, setTick] = useState(0); // ✅ Memoized — same reference across renders, so memo can bail out. const value = useMemo(() => ({ label: 'fixed' }), []); return ( <LabelContext value={value}> <Consumer onRender={onChildRender} /> <button onClick={() => setTick((t) => t + 1)}>{tick}</button> </LabelContext> );}import { render, screen, cleanup, act } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { UnstableValueApp, StableValueApp } from '../components/LabelProvider';
afterEach(cleanup);
test('an inline provider value re-renders a memoized consumer on every unrelated update', () => { let renders = 0; render(<UnstableValueApp onChildRender={() => renders++} />); const baseline = renders;
act(() => { screen.getByRole('button').click(); }); act(() => { screen.getByRole('button').click(); });
// Two unrelated parent re-renders (the tick counter) still re-rendered the // memoized consumer twice, because it got a brand-new context value object // both times — memo cannot see past the reference change. expect(renders - baseline).toBe(2);});
test("a useMemo'd provider value lets memo bail the consumer out", () => { let renders = 0; render(<StableValueApp onChildRender={() => renders++} />); const baseline = renders;
act(() => { screen.getByRole('button').click(); }); act(() => { screen.getByRole('button').click(); });
// Same two clicks, but the context value's reference never changes, so // memo bails the consumer out of both re-renders. expect(renders - baseline).toBe(0);});✓ an inline provider value re-renders a memoized consumer on every unrelated update✓ a useMemo'd provider value lets memo bail the consumer out
Test Files 1 passed (1) Tests 2 passed (2)See it render: provider value identity
Section titled “See it render: provider value identity”import { createContext, useContext, useState, useMemo, memo } from 'react';
const LabelContext = createContext({ label: 'fixed' });
const Consumer = memo(function Consumer() { const { label } = useContext(LabelContext); return <li>Consumer reads: {label}</li>;});
export default function App() { const [tick, setTick] = useState(0); const [stable, setStable] = useState(false); const stableValue = useMemo(() => ({ label: 'fixed' }), []); const value = stable ? stableValue : { label: 'fixed' }; return ( <LabelContext value={value}> <button onClick={() => setTick((t) => t + 1)}>Unrelated tick: {tick}</button> <button onClick={() => setStable((s) => !s)}> Provider value: {stable ? 'useMemo (stable)' : 'inline object (fresh every render)'} </button> <ul> <Consumer /> </ul> </LabelContext> );}| Component | Renders | Last commit (ms) |
|---|---|---|
| Click Run — commits will appear here. | ||
With the mode reading “inline object”, click Unrelated tick a few times — Consumer’s row climbs on every click, even though label itself never changes, because memo cannot see past the provider’s fresh object reference (proven above). Switch the mode to “useMemo (stable)” and click Unrelated tick again — Consumer’s row stops moving entirely, because the provider now hands out the same object reference every time.
Two mitigations for this class of bug:
- Split contexts. Put unrelated values in separate providers so a change to one doesn’t re-render consumers of the other. A common split: one context for the state and one for the dispatch/updater (the updater is stable, so components that only dispatch never re-render) — the
useReducerlesson builds this pattern out in full. - Keep the value stable. Memoize the provider’s
valueobject (useMemo) so it doesn’t get a new reference on every parent render, exactly as proven above.
Context has no selectors
Section titled “Context has no selectors”Splitting contexts is a manual, static fix — you decide at design time which fields live in which provider. What Context cannot do is let one consumer dynamically subscribe to “just field x of this object” while another subscribes to “just field y” from the same context: any change to the provider’s value re-renders every consumer of that context, full stop, regardless of which field a given consumer actually reads.
An external store’s selector (useStore(s => s.x)) re-renders a component only when the selected slice changes, comparing that narrower value instead of the whole store. Context has no equivalent — it compares the whole value reference. When an app has many consumers each caring about a different narrow slice of a large, frequently-changing value, that’s the signal to reach past Context for an external store (the next lesson).
Context is not a state manager
Section titled “Context is not a state manager”A crucial distinction: Context is a transport mechanism, not a state container. It moves a value down the tree; it does not hold or update state. The state itself still lives in a useState or useReducer in the provider component. “Using Context for state management” really means “useState/useReducer at the top, delivered by Context.”
function AuthProvider({ children }) { const [user, setUser] = useState(null); // the state lives here return <AuthContext value={{ user, setUser }}>{children}</AuthContext>; // Context just delivers it}Mental Model
Section titled “Mental Model”Think of a context as a radio broadcast, not a mailbox: the provider transmits one value, and every component tuned to that station (every useContext/use call for that context, anywhere below) receives the same broadcast at the same time, whether it wanted this particular update or not. There is no way to broadcast on sub-frequencies within one station — that’s what “no selectors” means. If different listeners need different update frequencies, you need separate stations (split contexts) or a receiver built for exactly that (an external store’s selector).
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
<Context> direct provider (React 19) | Shorter, no .Provider to remember | Requires React 19+; .Provider still needed on older versions |
use(Context) | Can be called conditionally/in loops | Slightly less familiar than useContext; same upward-search caveats apply |
| Single wide context | Simple to set up | Any value change re-renders every consumer, memo cannot help |
| Split contexts (state vs dispatch) | Dispatch-only consumers never re-render | More providers to wire up |
useMemo’d provider value | Prevents re-renders from a fresh object literal | One more dependency array to keep correct |
| External store (next lesson) | True per-slice selectors | Outside React; another API to learn |
Common Misconceptions
Section titled “Common Misconceptions”- “
memoon a consumer protects it from context updates.” It doesn’t —memoonly checks props. A context value change re-renders every consumer regardless ofmemo, as react.dev states directly and as proven above. - “If the data inside the value object didn’t change, the consumer won’t re-render.” It will, if the value is a new object literal — React compares the provider’s value by reference, not by deep equality.
- “Splitting contexts is the same as selectors.” Splitting is a manual, compile-time decision about which fields go where. A selector dynamically picks a slice of a single value at read time — Context has no such mechanism.
- “Context replaces
useState/useReducer.” It only transports whateveruseState/useReduceralready computed — the state still lives in the provider component.
Reading AI-generated code
Section titled “Reading AI-generated code”When you review a generated Provider component, check for these before trusting it:
- Does the provider’s JSX construct the
valueprop inline (value={{ ... }}orvalue={[a, b]}) instead of passing a variable held inuseMemo/useState? That’s a new reference every render, and every consumer re-renders — even ones wrapped inmemo. - Is a single context carrying both slow-changing data (like a logged-in user) and fast-changing data (like a live cursor position or a form’s every keystroke)? That belongs split into two contexts, or moved to an external store.
- Does the code assume wrapping a consumer in
memowill stop it from re-rendering on a context change? It won’t — verify by checking whether the provider’s value is stable, not by addingmemoto the consumer. - Is
use(Context)called somewhereuseContextwould also work fine (an unconditional top-level call)? Either is correct there — but if you seeusespecifically inside anif/loop, that’s why it’suseand notuseContext, not a mistake. - Does the component read the whole context value just to display one field, when a
useReducer+split-context (or external-store selector) would isolate it from unrelated updates?
🐛 Spot the bug
function CartProvider({ children }) {const [items, setItems] = useState([]);const [user, setUser] = useState(null);
return ( <CartContext value={{ items, user, addItem: (item) => setItems((prev) => [...prev, item]), }} > {children} </CartContext>);}Show the answer
The value object is built inline on every render of CartProvider — a brand-new object, even when neither items nor user actually changed. Every consumer of CartContext re-renders whenever CartProvider re-renders for any reason (say, an unrelated parent state update), and memo on those consumers cannot stop it, exactly as proven above with UnstableValueApp. The fix is useMemo(() => ({ items, user, addItem }), [items, user, addItem]) (with addItem itself wrapped in useCallback) — now the value only gets a new reference when items or user actually changes.