MENU
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>;
}
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>
</>;
}
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>
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.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>;
}
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>;
}
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>
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>;
}
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>
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.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
}