React Context API and useReducer for State Management
The React Context API lets components subscribe to shared data without threading it through every level of the component tree as a prop. Paired with useReducer, it gives you a lightweight state management system that handles complex transition logic without external libraries. Context solves the prop drilling problem, where data passes through components that do not need it just to reach the ones that do. useReducer solves the tangled state problem, where multiple state transitions happening at once make useState feel messy.
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. A single Provider can feed many consumers, and each consumer only subscribes to the context it reads.
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, and it is covered in depth in the performance section later in this guide.
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, where every piece of app state gets stuffed 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 are easy to test without rendering anything:
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
Reducers shine in forms, multi-step flows, and any scenario where a user action triggers several state changes at once. A typical sign that useReducer would help: you find yourself calling setState three or four times inside a single event handler. Because the reducer is a single function that receives the current state and an action description, all the transition logic lives in one place.
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. The useCart custom hook encapsulates the context lookup and the guard clause, so individual components never call useContext directly. This pattern keeps context access consistent across the codebase and surfaces missing Provider errors at the call site rather than through a confusing null reference later:
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 pitfall: wrapping state and dispatch in the same context object forces every consumer to re-render on any state change, even components that only need to dispatch actions. The object literal { state, dispatch } creates a new reference on every render, and React sees that as the Provider value changing. Splitting into two separate contexts solves this.
// 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 does not re-render when state changes. This pattern is worth the extra Provider nesting whenever you have components that dispatch frequently while others read state frequently.
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, keeping the setup minimal so the test focuses on the component’s behavior rather than the provider plumbing:
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, with no rendering required. This is one of the strongest reasons to use useReducer: you can verify every state transition by calling the reducer function with known inputs and checking the outputs, the same way you test any pure function:
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.
Split read and write paths
Context and useReducer work best when reading state and changing state are separate concerns. A display component can subscribe to the state it needs, while a button or form control can dispatch actions without knowing how the state is stored. That split keeps the tree easier to trace because you can see which parts of the UI are observers and which parts are writers. It also makes refactors less risky since the update logic stays in one reducer.
Keep reducers boring
A good reducer should feel almost unremarkable. It takes the current state and an action, then returns the next state with no side effects and no surprise branches. That boring shape is a strength. When the logic stays pure, you can test it without rendering, replay actions during debugging, and reason about state transitions as simple input-output pairs. If a reducer starts doing more than that, it is usually time to move the extra work somewhere else.
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