Context API and useReducer
Context API and useReducer are two React features that solve distinct problems. Context solves the prop drilling problem — passing data through components that don’t need it just to reach the ones that do. useReducer solves the tangled state problem — when multiple state transitions happen at once and useState starts feeling messy. Together they form a lightweight state management system without any external libraries.
The Prop Drilling Problem
Imagine a user object that lives at your app’s root and is needed five levels deep in a settings panel. Without Context, you pass it as a prop through four intermediate components that never touch it:
function App() {
return <Dashboard user={currentUser} />;
}
function Dashboard({ user }) {
return (
<Layout>
<Sidebar user={user} />
<Main user={user} />
</Layout>
);
}
function Sidebar({ user }) {
return <UserAvatar user={user} />;
}
function Main({ user }) {
return <SettingsPanel user={user} />;
}
function SettingsPanel({ user }) {
return <p>{user.name}</p>;
}
This is prop drilling. The Sidebar and Main components don’t care about user — they just pass it along. Change the prop name or shape, and you update every intermediate component. Context fixes this by creating a named channel that any component can subscribe to directly.
Creating and Providing Context
The createContext function creates a context object with a Provider component. Wrap any subtree with the Provider to make a value available to all descendants:
import { createContext, useContext } from 'react';
const UserContext = createContext(null);
function App() {
return (
<UserContext.Provider value={currentUser}>
<Dashboard />
</UserContext.Provider>
);
}
createContext takes a single argument: the default value. React uses this when there’s no matching Provider above the component that calls useContext. The default value is static — it never changes, so treat it as a fallback.
Any component inside the Provider tree can read the value with useContext:
function SettingsPanel() {
const user = useContext(UserContext);
return <p>{user.name}</p>;
}
The useContext hook returns the context value from the closest Provider above the component in the tree. No prop threading required.
Reading Multiple Contexts
A component can read from more than one context. Each useContext call is independent:
const ThemeContext = createContext('light');
const UserContext = createContext(null);
function Header() {
const theme = useContext(ThemeContext);
const user = useContext(UserContext);
return (
<header className={theme}>
{user ? <Avatar src={user.avatar} /> : <LoginLink />}
</header>
);
}
The key thing to understand: when the Provider’s value changes reference, every component subscribed to that context re-renders. This is the most common performance footgun with Context. More on this later.
When to Reach for Context
Context is not a replacement for props. The React team’s official guidance is to prefer props and only use Context when data genuinely belongs to many components across the tree.
Good fits for Context:
- Theme (dark/light mode)
- User authentication state
- Language or locale preference
- Anything that truly applies to most components in an app
Avoid Context for:
- Ephemeral state like form inputs or UI toggles
- State that only one or two components need
- State that could be composed differently with children-as-function patterns
The “God Context” anti-pattern — stuffing every piece of app state into a single context — causes every consumer to re-render whenever anything changes. Split contexts by concern.
Managing State with useReducer
useReducer is a state hook that suits complex or multi-field state. The signature:
const [state, dispatch] = useReducer(reducer, initialArg, init?);
state— the current state valuedispatch— a stable function that triggers a state updatereducer— a pure function:(state, action) => newStateinitialArg— the initial state valueinit— optional lazy initialization function
The reducer function takes the current state and an action object, then returns the next state. Because reducers are pure functions, they’re easy to test — no rendering required:
function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
case 'RESET':
return { count: 0 };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<span>{state.count}</span>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
</div>
);
}
The dispatch function never changes across renders. This is useful: a component that only needs to dispatch actions won’t re-render when state changes elsewhere.
Use useReducer when:
- State consists of multiple related fields
- Updates involve conditional logic or multiple sub-updates
- The same action could produce different results depending on current state
- You want to centralize state logic for testability
Combining Context and useReducer
This is where the two features complement each other. useReducer manages the state; Context distributes it. Together they form a lightweight Redux-like store:
// cartContext.js
import { createContext, useContext, useReducer } from 'react';
const CartContext = createContext(null);
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM':
return { ...state, items: [...state.items, action.payload] };
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter(item => item.id !== action.payload)
};
case 'CLEAR_CART':
return { ...state, items: [] };
default:
return state;
}
}
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
}
Components consume the cart without any prop drilling:
function CartIcon() {
const { state } = useCart();
return <span>Cart ({state.items.length})</span>;
}
function CheckoutButton() {
const { dispatch } = useCart();
return (
<button onClick={() => dispatch({ type: 'CLEAR_CART' })}>
Checkout
</button>
);
}
The reducer centralizes all cart logic. State is never mutated directly — every change goes through dispatch. This makes the state transitions predictable and testable.
Performance: Keeping Re-Renders Under Control
Context re-renders catch many developers off guard. When a Provider’s value changes reference, every useContext that reads that context re-renders — regardless of whether the component actually uses the changed value.
The solution is to split contexts so each one holds only the data that changes together:
// Bad: everything re-renders on any change
<AppContext.Provider value={{ user, theme, cart, settings }}>
// Good: each concern isolated
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<CartContext.Provider value={cart}>
When cart changes, only components subscribed to CartContext re-render.
Another common issue: wrapping state and dispatch in the same object. The whole object gets a new reference on every state update, which triggers re-renders even for components that only call dispatch:
// dispatch is stable but the object changes every render
<CartContext.Provider value={{ state, dispatch }}>
// Better: split into two contexts
<CartStateContext.Provider value={state}>
<CartDispatchContext.Provider value={dispatch}>
With two separate contexts, a component that only calls dispatch doesn’t re-render when state changes.
Lazy Initialization
For expensive initial state computation, pass a function as the third argument to useReducer. React calls it only once, on the initial render:
function buildInitialStateFromStorage() {
const saved = localStorage.getItem('cart');
return saved ? JSON.parse(saved) : { items: [] };
}
const [state, dispatch] = useReducer(cartReducer, null, buildInitialStateFromStorage);
The init argument (third parameter) receives initialArg and returns the initial state. This keeps initialization logic out of the render path.
Testing Components with Context
A component that calls useContext won’t work correctly without a Provider in the tree. The standard approach is to wrap with the Provider in your test:
import { render, screen } from '@testing-library/react';
import { CartProvider, useCart } from './cartContext';
function TestCartDisplay() {
const { state } = useCart();
return <p>{state.items.length} items</p>;
}
test('displays item count', () => {
render(
<CartProvider>
<TestCartDisplay />
</CartProvider>
);
expect(screen.getByText('0 items')).toBeInTheDocument();
});
The reducer itself can be tested in complete isolation — no rendering required:
test('ADD_ITEM appends to items array', () => {
const state = { items: [] };
const action = { type: 'ADD_ITEM', payload: { id: 1, name: 'Widget' } };
const result = cartReducer(state, action);
expect(result.items).toHaveLength(1);
expect(result.items[0].name).toBe('Widget');
});
Because reducers are pure functions, they never surprise you in tests.
See Also
- React Components and JSX — understanding components as the building blocks
- React Props and State — the foundations of data flow in React
- React Introduction and Setup — extracting reusable logic into custom Hooks
Written
- File: sites/jsguides/src/content/tutorials/react-context-and-reducers.md
- Words: ~1150
- Read time: 6 min
- Topics covered: Context API (createContext, useContext, Provider), useReducer (reducer pattern, dispatch, lazy init), combining Context + useReducer, performance (split contexts, stable dispatch), testing with Provider wrappers
- Verified via: react.dev/reference/react/createContext, react.dev/reference/react/useContext, react.dev/reference/react/useReducer
- Unverified items: none