Context and Reducers

Props travel in exactly one direction: a parent hands a value to its child, and the child hands it on. That rule is what makes a React tree easy to reason about, and it is what makes two situations painful. The first is distance — a theme, a signed-in user or a locale that thirty components need, threaded by hand through every component in between. The second is complexity — state that a dozen handlers update in a dozen slightly different ways, until no single place tells you how that state can change.React ships one answer for each. Context lets a component publish a value for an entire subtree, so any descendant can read it without a prop ever being declared in between. useReducer collects every possible state transition into one pure function, so a component dispatches a description of what happened and the reducer decides what the state becomes.Each is useful alone. Combined, they are close to a complete state-management layer built out of nothing but React, and they handle a surprising amount of an application before an external store such as Redux (Section 2.18) starts to pay for itself.

2.5.1 Prop Drilling and the Case for Context

Prop drilling is the name for passing a value down through components that have no use for it. It only needs to reach the bottom, but every component on the path has to accept it, name it, and forward it.

function App() {

const [theme, setTheme] = useState('dark');

return <Layout theme={theme} onToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')} />;

}

const Layout = ({ theme, onToggle }) => <Panel theme={theme} onToggle={onToggle} />;

const Panel = ({ theme, onToggle }) => <ThemeButton theme={theme} onToggle={onToggle} />;

function ThemeButton({ theme, onToggle }) { // the only component that cares

return <button className={theme} onClick={onToggle}>Theme: {theme}</button>;

}

Listing 2.31: A theme prop forwarded through components that never use it Layout and Panel are now coupled to a feature they do not implement. Rename theme, add a second value, or move ThemeButton elsewhere, and both change with it. In a real tree the path is longer.Figure 2.18: The same value delivered by drilling and by contextContext is not the first thing to reach for. If a parent merely renders a deep child, pass the child as JSX instead of a prop — <Layout><ThemeButton /></Layout> lets App build the element itself, and Layout never sees theme at all (Section 2.11.3). If several siblings need the same data, lifting state to their nearest common parent (Section 2.3.7) is usually enough. Context earns its place when the value is genuinely global to a deep or wide subtree: the theme, the signed-in user, the interface language, or one large feature such as a shopping cart. The price is that every reader needs a matching provider above it in every tree it renders in, tests included.

2.5.2 Creating and Providing Context

A context is created once, outside any component, with createContext(defaultValue) — usually in its own module, so that the provider and every reader import the same object. What it returns holds no data; it is an identity, a key that a provider and a reader agree on.A component provides a value by rendering the context itself as a component with a value prop, and reads the nearest provided value with useContext. With no provider above it, a reader gets the defaultValue, which is why null is the right default when there is no sensible fallback.

const ThemeContext = createContext('light'); // the fallback value

function Badge() {

const theme = useContext(ThemeContext); // reads the nearest provider above

return <b className={theme}>{theme}</b>;

}

export default function App() {

return <>

<Badge /> {/* no provider above: renders "light" */}

<ThemeContext value="dark"><Badge /></ThemeContext>

</>;

}

Listing 2.32: Providing a value, and falling back to the default Before React 19 the provider had to be written <ThemeContext.Provider value="dark">. That form still works and you will meet it in every older code base, but <ThemeContext> is now the documented syntax and .Provider is slated for removal. The matching <ThemeContext.Consumer> render-prop component is legacy too.React walks up from the reader to find the closest provider, so a provider rendered by the same component that reads the context has no effect on that read. Nesting two providers of one context is legal: the inner shadows the outer for its own subtree, which is how a preview pane renders a light card inside a dark application. Note: use(ThemeContext) reads a context too, and unlike useContext it may be called inside an if or a loop (Section 2.8.6). Prefer useContext unless you actually need a conditional read.

2.5.3 Passing an Object Through Context

A single string is rarely enough. A theme is a set of related values, and the natural move is to publish an object. Nothing about the API changes: value takes any JavaScript value, and useContext returns whatever the nearest provider passed.

<div id="root"></div>

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>

<script type="importmap">

{ "imports": { "react": "https://esm.sh/react@19.3.0",

"react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }

</script>

<script type="text/babel" data-type="module">

import React, { createContext, useContext } from 'react';

import { createRoot } from 'react-dom/client';

const themes = { light: { foreground: '#000000', background: '#eeeeee' },

dark: { foreground: '#ffffff', background: '#222222' } };

const ThemeContext = createContext(themes.light);

const App = () => <ThemeContext value={themes.dark}><Toolbar /></ThemeContext>;

const Toolbar = () => <div style={{ padding: '12px' }}><ThemedButton /></div>;

function ThemedButton() {

const t = useContext(ThemeContext);

return <button style={{ background: t.background, color: t.foreground, border: 0,

padding: '10px 16px', fontSize: '16px' }}>Styled by theme context</button>;

}

createRoot(document.getElementById('root')).render(<App />);

</script>

Listing 2.33: A theme object read two levels below the provider Figure 2.19: Browser output of Listing 2.33Toolbar neither receives nor forwards anything, and ThemedButton would render identically ten levels deeper. Swapping themes.dark for themes.light on the provider restyles the whole subtree.The detail that matters is identity. React compares the old and new value with Object.is and re-renders every reader when they differ. themes.dark is created once at module scope, so it is the same object on every render and readers are left alone. Write it inline — value={{ foreground: '#fff', background: '#222' }} — and a brand-new object is allocated each time App renders, so every reader re-renders even though nothing about the theme changed. Section 2.5.5 covers the fix. Tip: An object-valued context is where a TypeScript type earns its keep. Declare createContext<Theme | null>(null) and throw from a small useTheme() wrapper when the value is null; that turns a missing provider into a clear error instead of a mysterious undefined (Section 2.13.6).

2.5.4 Passing a Function Through Context

Context carries behavior as happily as it carries data. Publishing a state setter — or, later, a dispatch function — lets a deeply nested component change state that lives near the root, with no callback prop in between.

const CounterContext = createContext(null);

function Page() {

const [count, setCount] = useState(0);

return <CounterContext value={setCount}>

<p>Counter: {count}</p><A />

</CounterContext>;

}

const A = () => <B />, B = () => <C />, C = () => <D />, D = () => <Plus />;

function Plus() {

const bump = useContext(CounterContext); // any inner function can call it too

return <button onClick={() => bump(n => n + 1)}>+1</button>;

}

Listing 2.34: A state setter delivered five levels down A through D are pass-through components that never mention the counter, yet clicking the button three levels below them moves the number that Page owns.This works cleanly for one reason worth remembering: the setter React hands you from useState has a stable identity. It is the same function object on every render, so the context value never changes and no reader re-renders because of it. A handler you write yourself — value={() => setCount(c => c + 1)} — is a new function each render and does the opposite, forcing every reader to re-render whenever Page does; useCallback (Section 2.7.4) restores stability there. Publishing a raw setter also leaks how the state is stored. An object of named actions such as { increment, reset } reads better at the call site, and a reducer's dispatch is better still once the actions multiply.

2.5.5 Context Performance Pitfalls

Context has exactly one update rule: when a provider's value differs from its previous value under Object.is, every component below it that reads that context re-renders. Two consequences trip people up. The first is identity: an object or array written inline in JSX is rebuilt on every render of the providing component, so the value always "differs" even when the data is identical. The second is that memo (Section 2.7.2) does not help — a memoized component whose props are unchanged still re-renders if it reads a context whose value changed. Memoization guards props, not context, and the React Compiler (Section 2.7.7) does not change that.

const AuthContext = createContext(null); // changes when the user changes

const AuthActions = createContext(null); // never changes after mount

function AuthProvider({ children }) {

const [user, setUser] = useState(null);

const actions = useMemo(() => ({

login: (u) => setUser(u), logout: () => setUser(null)

}), []); // no dependencies: stable forever

return <AuthContext value={user}>

<AuthActions value={actions}>{children}</AuthActions>

</AuthContext>;

}

Listing 2.35: Two contexts, one stable value each A login button reads AuthActions only, so it never re-renders when the user changes. Note also that AuthProvider receives its subtree through children: that JSX is created by the parent, so it is the same element object on every render of AuthProvider, and React skips re-rendering it when only the provider's state changed. Technique What it fixes Cost useMemo on the value A new object identity on every parent render A dependency array to maintain Split data and actions Action-only readers re-render on data changes Two providers, two hooks Provider takes children The whole app re-renders with the provider One extra component use-context-selector Readers re-render on fields they ignore A dependency; last release 2024 Table 2.10: Keeping a context update from re-rendering an entire subtree Note: React has no built-in context selector, so a component reading a large object re-renders on every field change. Splitting one fat context into several narrow ones is the usual answer; if that stops scaling, an external store with selectors — Zustand 5.0.15 or Jotai 3.0.0 — solves the problem at the source.

2.5.6 useReducer

useReducer is useState with the update logic moved out of the event handlers. You write a reducer — a pure function (state, action) => nextState — and the Hook returns the current state plus a dispatch function. Handlers no longer compute anything; they announce what happened.

<div id="root"></div>

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>

<script type="importmap">

{ "imports": { "react": "https://esm.sh/react@19.3.0",

"react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }

</script>

<script type="text/babel" data-type="module">

import React, { useReducer } from 'react';

import { createRoot } from 'react-dom/client';

function reducer(state, action) {

switch (action.type) {

case 'increment': return { count: state.count + 1 };

case 'reset': return { count: 0 };

default: throw new Error('Unknown action: ' + action.type);

}

}

function Counter() {

const [state, dispatch] = useReducer(reducer, { count: 0 });

return <p style={{ font: '16px system-ui' }}>Count: {state.count}{' '}

<button onClick={() => dispatch({ type: 'increment' })}>+1</button>{' '}

<button onClick={() => dispatch({ type: 'reset' })}>Reset</button></p>;

}

createRoot(document.getElementById('root')).render(<Counter />);

setTimeout(() => [0, 0, 0].forEach(() =>

document.querySelectorAll('button')[0].click()), 600); // three scripted clicks

</script>

Listing 2.36: A counter driven by a reducer Figure 2.20: Browser output of Listing 2.36Every way this state can change is now visible in five lines, which is the real payoff: with useState the same transitions would be spread across the handlers, and the next one would be easy to miss. Prefer a reducer when the next state depends on the previous one, when several values move together, or when the same transition fires from more than one place. Keep useState for a checkbox.Two behaviors follow from the reducer being pure. React compares the returned state with the current one using Object.is and skips the re-render when they match, so mutating state in place and returning it does nothing at all. And in Strict Mode, development builds call the reducer twice per dispatch to expose exactly that kind of impurity. dispatch itself has a stable identity: safe to pass down, safe to omit from an Effect's dependencies.

2.5.7 Reducer Initialization

useReducer(reducer, initialArg, init?) takes an optional third argument. Without it the initial state is initialArg itself. With it, React computes the initial state once as init(initialArg) and never calls init again on later renders.

function init(initialCount) {

return { count: initialCount, history: [] }; // runs once, on mount

}

function reducer(state, action) {

switch (action.type) {

case 'increment': return { ...state, count: state.count + 1 };

case 'reset': return init(action.payload); // reuse the same shape

default: throw new Error('Unknown action: ' + action.type);

}

}

function Counter({ initialCount }) {

const [state, dispatch] = useReducer(reducer, initialCount, init);

return <button onClick={() => dispatch({ type: 'reset', payload: initialCount })}>Reset</button>;

}

Listing 2.37: An initializer used for both setup and reset Two things make this worth the extra argument. The initial state is often derived — parsed from localStorage, built from a prop, computed from a list — and writing useReducer(reducer, init(initialCount)) would run that work on every render only to throw the result away. And extracting the setup into a named function lets the reset action rebuild the starting state without duplicating its shape.Like the reducer, init must be pure: Strict Mode calls it twice in development and discards one result, so an initializer that writes to localStorage or bumps a counter misbehaves visibly. That double call is the point — it surfaces impurity while you are still writing the code. Tip: Reducers that spread nested objects get noisy fast. Immer 11.1.18 turns case 'increment': draft.count++ into a correct immutable update, and its use-immer package exports useImmerReducer, whose reducer receives a mutable draft.

2.5.8 Combining Context with a Reducer

The two tools fit together into a small, self-contained store. A reducer owns the data and the transitions; one context publishes the state, a second publishes dispatch. Components that only trigger actions subscribe to the second context and are never re-rendered by a data change.

<div id="root"></div>

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>

<script type="importmap">

{ "imports": { "react": "https://esm.sh/react@19.3.0",

"react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }

</script>

<script type="text/babel" data-type="module">

import React, { createContext, useContext, useReducer } from 'react';

import { createRoot } from 'react-dom/client';

const TasksContext = createContext(null), TasksDispatch = createContext(null);

function reducer(tasks, action) {

switch (action.type) {

case 'added': return [...tasks, { id: Date.now(), text: action.text }];

case 'cleared': return [];

default: throw new Error(action.type);

}

}

function TasksProvider({ children }) {

const [tasks, dispatch] = useReducer(reducer, [{ id: 1, text: 'Split the context' }]);

return <TasksContext value={tasks}>

<TasksDispatch value={dispatch}>{children}</TasksDispatch></TasksContext>;

}

function TaskList() { // reads, never dispatches

const tasks = useContext(TasksContext);

return <ul style={{ font: '16px system-ui', margin: '4px 0' }}>

{tasks.map(t => <li key={t.id}>{t.text}</li>)}</ul>;

}

function Toolbar() { // dispatches, never reads

const d = useContext(TasksDispatch);

return <><button onClick={() => d({ type: 'added', text: 'Ship it' })}>Add</button>{' '}

<button onClick={() => d({ type: 'cleared' })}>Clear</button></>;

}

createRoot(document.getElementById('root')).render(

<TasksProvider><TaskList /><Toolbar /></TasksProvider>);

setTimeout(() => document.querySelector('button').click(), 600); // scripted click

</script>

Listing 2.38: A task list whose state and dispatch travel by context Figure 2.21: Browser output of Listing 2.38The second item in the screenshot was added by a scripted click on Add. Toolbar received no prop and never read the task list, yet it changed it; TaskList never learned where the data came from. Each component subscribes to exactly one of the two contexts, which is what keeps the toolbar out of every list re-render.In a real project TasksProvider, the reducer and two one-line hooks — const useTasks = () => useContext(TasksContext) — live in one TasksContext.js module, and nothing outside it imports the raw contexts. That module is then the whole public interface of the feature: if it later becomes a Redux slice (Section 2.18.12) or a Zustand store, the components calling useTasks() do not change. Warning: This pattern still re-renders every reader of TasksContext on every dispatch — fine for dozens of components, a problem for thousands. Section 2.5.5 lists the ways out.

2.5.9 Forcing a Re-render

Because React bails out when a reducer returns the current state under Object.is, the inverse is also true: a reducer that returns a fresh value every time always re-renders. That is the whole trick behind the classic forceUpdate idiom.

const [, forceUpdate] = useReducer(x => x + 1, 0); // the state itself is ignored

function handleExternalChange() {

forceUpdate(); // no argument needed

}

Listing 2.39: A reducer used only for its re-render useState can do the same with setTick(t => t + 1), but the reducer form is shorter and makes the intent obvious: the number is never read, only incremented.Reaching for it usually means state is being kept somewhere React cannot see — a mutated object, a ref, a module-level variable, a library instance. The fix is almost always to move that value into useState or useReducer so the update is declared rather than forced. For a mutable external store, useSyncExternalStore (Section 2.8.8) does the same job correctly under concurrent rendering. Warning: A forced update re-renders the component and its whole subtree with no bail-out anywhere, so in a loop or a frequent event it is an easy way to make a React application feel slow.