React 41 ⚛️ Combining Context and useReducer
A reducer centralizes state transitions. A context distributes state across the tree. Each solves a different problem in isolation: useReducer gives a component a single function that computes the next state from the current state and an action, and createContext gives a component a way to make a value available to every descendant without prop drilling. Combined, they form the standard pattern for application-level state management in React without a third-party library. The reducer owns the state and the dispatch function; the context distributes them; the components read and dispatch without knowing where the state lives.
The combination is not a coincidence. The reducer’s dispatch function is stable across renders — React guarantees that the identity of dispatch never changes — which means it can be placed in a context without causing consumers to re-render when the state changes. The state value changes on every dispatch, and the components that read it re-render. But the components that only dispatch do not need the state, and if state and dispatch are placed in separate contexts, those components never re-render at all. This separation is the key optimization, and it is the pattern that the React documentation describes for scaling state management beyond a single component.
The pattern also solves the prop-drilling problem for deeply nested components. A form in a modal five levels deep can read the application’s state and dispatch actions without receiving props through every intermediate component. The state is available where it is needed, and the components that do not need it are not affected. This is the same problem that context solves in general, but the combination with a reducer adds the discipline of a single state-transition function. Every change goes through the reducer, every action is named, and the state logic is centralized and testable.
This chapter covers three areas. First, why the combination exists — the problem of sharing state across a tree and the separation of state from dispatch. Second, how to wire the two together — the provider component that owns the reducer, the two contexts, and the guarded hooks for consuming them. Third, how to structure components around the pattern — the split between components that read state and components that dispatch actions, and the performance implications of each. The chapter ends with a complete example session, a quick reference, best practices, common pitfalls, real-world examples, and diagrams showing the state and dispatch flow.
Key point: Combine useReducer with context by creating a provider that owns the reducer and places the state and dispatch in separate contexts. Components that read state re-render when the state changes; components that only dispatch never re-render. The dispatch function is stable across renders, so it is safe to place in a context. Guard the consuming hooks with a null check so a missing provider produces a clear error.
Why combining context and useReducer exists
The prop-drilling problem. State that lives at the top of the tree and is needed at the bottom must pass through every intermediate component. The intermediate components receive the state as a prop, forward it to their children, and never use it themselves. This is prop drilling, and it pollutes the intermediate components with data they do not care about. Context removes the drilling by making the state available to any descendant that reads the context. The provider supplies the value; the consumers read it; the intermediate components are unaffected .
The scattered-update problem. A component with many useState calls has many setters, and the logic that updates the state is spread across event handlers. When several fields change together, the setters must be called in the right order and with the right values. A reducer centralizes the transitions: one function handles every action, and the state is always the result of the reducer. The combination puts the centralized state in a context, so every component in the tree can dispatch actions without the state being passed through props .
The re-render problem. When a context’s value changes, every component that reads the context re-renders. If the state and the dispatch function are in the same context, then every dispatch causes every consumer to re-render — including the components that only dispatch and do not read the state. The fix is to split the context into two: one for the state and one for the dispatch. The dispatch function never changes, so the dispatch context’s value never changes, and the components that read only the dispatch context never re-render. This is the key optimization of the pattern .
The implicit-dependency problem. A component that reads from a context depends on that context, but the dependency is not visible in its props. This makes the component harder to reuse outside the provider and harder to test without wrapping it. The guarded hook pattern addresses this: the hook throws when the context is null, so a missing provider produces an immediate error instead of a silent undefined. The hook also encapsulates the context object, so consumers cannot bypass the guard by calling useContext directly .
The testability problem. A reducer is a pure function, so it can be tested in isolation with no rendering. The provider that owns the reducer is a component, but the reducer itself is not. The state logic can be verified with a series of expect(reducer(state, action)).toEqual(expected) assertions. The components that consume the context are tested by wrapping them in the provider and asserting on the rendered output. The separation of concerns is what makes both kinds of tests possible .
The trade-off. The pattern is more code than a single useState in a component. It requires a provider component, a reducer function, two context objects, and two hooks. For a small piece of state that lives in one component and is used by one child, the overhead is not justified. The pattern pays off when the state is shared across many components, when the transitions are complex enough to benefit from a reducer, or when the state needs to be tested independently of the UI. The trade-off is between the ceremony of the pattern and the structure it provides.
a. The provider component
The provider component owns the reducer and supplies the state and dispatch to the tree. It renders two context providers, one for the state and one for the dispatch .
import { createContext, useReducer } from 'react';
const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);
function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [
...tasks,
{ id: action.id, text: action.text, done: false },
];
}
case 'changed': {
return tasks.map((t) =>
t.id === action.task.id ? action.task : t
);
}
case 'deleted': {
return tasks.filter((t) => t.id !== action.id);
}
default: {
throw new Error(`Unknown action: ${action.type}`);
}
}
}
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>
{children}
</TasksDispatchContext>
</TasksContext>
);
}
The reducer is a pure function with a switch statement. The default case throws, which makes an unknown action an immediate error. The provider calls useReducer once, and the state and dispatch are placed in separate contexts. The provider wraps the tree that needs the state, and every descendant can read the state, the dispatch, or both .
The state context’s value is the state itself, which changes on every dispatch. The dispatch context’s value is the dispatch function, which never changes. This is what makes the split valuable: a component that reads only the dispatch context is not affected by state changes.
b. The consuming hooks
Two hooks consume the two contexts. Each hook checks for null and throws if the provider is missing. This converts a silent undefined into a clear error.
import { useContext } from 'react';
export function useTasks() {
const context = useContext(TasksContext);
if (context === null) {
throw new Error('useTasks must be used within a TasksProvider');
}
return context;
}
export function useTasksDispatch() {
const context = useContext(TasksDispatchContext);
if (context === null) {
throw new Error('useTasksDispatch must be used within a TasksProvider');
}
return context;
}
The hooks are the public API of the module. The context objects are not exported, so consumers cannot bypass the guard by calling useContext(TasksContext) directly. The error message names the hook and the missing provider, which makes the structural mistake visible in the console .
The useTasks hook returns the state. A component that calls it re-renders whenever the state changes. The useTasksDispatch hook returns the dispatch function. A component that calls it never re-renders because of a state change, because the dispatch function is stable. This is the pattern that separates the components that display the state from the components that trigger changes.
c. Structuring components around the pattern
The pattern divides components into three roles: the provider, the readers, and the dispatchers. The provider is rendered once, near the root of the subtree that needs the state. The readers call useTasks and re-render when the state changes. The dispatchers call useTasksDispatch and dispatch actions without reading the state.
A reader component renders the state:
function TaskList() {
const tasks = useTasks();
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.text}</li>
))}
</ul>
);
}
TaskList reads the state with useTasks. It re-renders whenever the task list changes, which is exactly what is needed: the list must re-render to show the new state.
A dispatcher component triggers an action:
function AddTask() {
const dispatch = useTasksDispatch();
const [text, setText] = useState('');
function handleSubmit(e) {
e.preventDefault();
dispatch({ type: 'added', id: nextId++, text });
setText('');
}
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button type="submit">Add</button>
</form>
);
}
AddTask calls useTasksDispatch and never reads the state. It has its own local useState for the input value, which is local to the component and does not need to be in the context. When the form is submitted, it dispatches an action. The dispatch triggers a state change, the readers re-render, but AddTask does not, because it does not read the state context.
A combined component reads and dispatches:
function TaskItem({ task }) {
const dispatch = useTasksDispatch();
return (
<li>
<input
type="checkbox"
checked={task.done}
onChange={(e) =>
dispatch({
type: 'changed',
task: { ...task, done: e.target.checked },
})
}
/>
{task.text}
<button
onClick={() => dispatch({ type: 'deleted', id: task.id })}
>
Delete
</button>
</li>
);
}
TaskItem receives the task as a prop from its parent, which is a reader. It dispatches changes and deletions. It does not read the state context, so it does not re-render when other tasks change. The parent re-renders and passes the new task object down.
The division of labor is what makes the pattern efficient. The readers re-render on state changes, which is necessary. The dispatchers never re-render because of state changes, which is an optimization. The components that do both re-render on state changes only if they read the state, which is a deliberate choice.
Complete Example Session
// ============================================
// PART 1: THE REDUCER
// ============================================
function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [
...tasks,
{ id: action.id, text: action.text, done: false },
];
}
case 'changed': {
return tasks.map((t) =>
t.id === action.task.id ? action.task : t
);
}
case 'deleted': {
return tasks.filter((t) => t.id !== action.id);
}
default: {
throw new Error(`Unknown action: ${action.type}`);
}
}
}
// ============================================
// PART 2: THE CONTEXTS
// ============================================
import { createContext } from 'react';
const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);
// ============================================
// PART 3: THE PROVIDER
// ============================================
import { useReducer } from 'react';
const initialTasks = [
{ id: 0, text: 'Visit Kafka Museum', done: true },
{ id: 1, text: 'Watch a puppet show', done: false },
{ id: 2, text: 'Lennon Wall pic', done: false },
];
let nextId = 3;
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>
{children}
</TasksDispatchContext>
</TasksContext>
);
}
// ============================================
// PART 4: THE CONSUMING HOOKS
// ============================================
import { useContext } from 'react';
export function useTasks() {
const context = useContext(TasksContext);
if (context === null) {
throw new Error('useTasks must be used within a TasksProvider');
}
return context;
}
export function useTasksDispatch() {
const context = useContext(TasksDispatchContext);
if (context === null) {
throw new Error('useTasksDispatch must be used within a TasksProvider');
}
return context;
}
// ============================================
// PART 5: A READER COMPONENT
// ============================================
function TaskList() {
const tasks = useTasks();
return (
<ul>
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</ul>
);
}
// ============================================
// PART 6: A DISPATCHER COMPONENT
// ============================================
function AddTask() {
const dispatch = useTasksDispatch();
const [text, setText] = useState('');
function handleSubmit(e) {
e.preventDefault();
dispatch({ type: 'added', id: nextId++, text });
setText('');
}
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a task"
/>
<button type="submit">Add</button>
</form>
);
}
// ============================================
// PART 7: A COMPONENT THAT DISPATCHES
// ============================================
function TaskItem({ task }) {
const dispatch = useTasksDispatch();
return (
<li>
<input
type="checkbox"
checked={task.done}
onChange={(e) =>
dispatch({
type: 'changed',
task: { ...task, done: e.target.checked },
})
}
/>
<span>{task.text}</span>
<button
onClick={() => dispatch({ type: 'deleted', id: task.id })}
>
Delete
</button>
</li>
);
}
// ============================================
// PART 8: THE APP
// ============================================
export default function TaskApp() {
return (
<TasksProvider>
<h1>Day off in Kyoto</h1>
<AddTask />
<TaskList />
</TasksProvider>
);
}
// ============================================
// PART 9: THE RE-RENDER BEHAVIOR
// ============================================
// AddTask dispatches but does not read state.
// → It never re-renders because of a state change.
//
// TaskList reads state.
// → It re-renders whenever the task list changes.
//
// TaskItem dispatches but does not read state.
// → It re-renders only when its parent passes new props.
//
// The split between state and dispatch contexts is what
// makes this possible.
// ============================================
// PART 10: TESTING THE REDUCER
// ============================================
// The reducer is a pure function.
// It can be tested without rendering.
test('added action appends a task', () => {
const state = [];
const next = tasksReducer(state, {
type: 'added',
id: 1,
text: 'New task',
});
expect(next).toEqual([
{ id: 1, text: 'New task', done: false },
]);
});
test('deleted action removes a task', () => {
const state = [
{ id: 1, text: 'A', done: false },
{ id: 2, text: 'B', done: false },
];
const next = tasksReducer(state, { type: 'deleted', id: 1 });
expect(next).toEqual([{ id: 2, text: 'B', done: false }]);
});
test('unknown action throws', () => {
expect(() => tasksReducer([], { type: 'unknown' })).toThrow();
});
The ten parts show the reducer, the contexts, the provider, the consuming hooks, a reader component, a dispatcher component, a component that dispatches, the app, the re-render behavior, and testing the reducer.
Quick Reference
The Pattern
| Part | Purpose |
|---|---|
| Reducer | Pure function that computes the next state |
| State context | Distributes the state to readers |
| Dispatch context | Distributes the dispatch function to dispatchers |
| Provider | Owns the reducer and renders both contexts |
useTasks | Reads the state; re-renders on changes |
useTasksDispatch | Reads the dispatch; never re-renders on state changes |
Context Values
| Context | Value | Changes on |
|---|---|---|
| State context | The state object | Every dispatch |
| Dispatch context | The dispatch function | Never |
Component Roles
| Role | Hook | Re-renders on state change |
|---|---|---|
| Reader | useTasks | Yes |
| Dispatcher | useTasksDispatch | No |
| Both | Both hooks | Yes |
The Guarded Hook
| Situation | Behavior |
|---|---|
| Provider present | Returns the context value |
| Provider missing | Throws an error |
Context is null | Throws an error |
| Hook called outside setup | Standard hook rules apply |
Best Practices
✅ Do This:
// Split state and dispatch into separate contexts
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}> // ✅
// Guard the consuming hooks
if (context === null) throw new Error('useTasks requires TasksProvider'); // ✅
// Keep the context objects private
export function useTasks() { ... } // ✅
// Use the dispatch hook for components that only dispatch
const dispatch = useTasksDispatch(); // ✅
// Test the reducer as a pure function
expect(reducer(state, action)).toEqual(expected); // ✅
// Throw on unknown actions
default: throw new Error(`Unknown action: ${action.type}`); // ✅
❌ Don’t Do This:
// Don't put state and dispatch in one context
<AppContext value={{ state, dispatch }}> // ❌
// Don't export the raw context object
export const TasksContext = createContext(null); // ❌
// Don't skip the null guard
const tasks = useContext(TasksContext); // may be null // ❌
// Don't dispatch inside the reducer
dispatch({ type: 'other' }); // ❌
// Don't use the state hook in a dispatcher-only component
const tasks = useTasks(); // causes unnecessary re-renders // ❌
// Don't mutate state in the reducer
state.push(task); return state; // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| All consumers re-render on dispatch | State and dispatch in one context | Split into two contexts |
| Dispatcher re-renders unnecessarily | Component reads state it does not use | Use the dispatch hook only |
| Missing provider goes unnoticed | No null guard | Guard the hook with a null check |
| Reducer mutates state | Confusing reducers with setters | Return new objects |
| Context object exported | Convenience | Export only the hooks |
| Dispatch identity changes | Misunderstanding React | dispatch is stable; do not memoize |
| Reducer too large | Handling every action in one function | Split by domain |
Real-World Examples
1. The Reducer
function reducer(state, action) {
switch (action.type) {
case 'added': return [...state, action.item];
default: throw new Error(action.type);
}
}
2. The Two Contexts
const StateContext = createContext(null);
const DispatchContext = createContext(null);
3. The Provider
function Provider({ children }) {
const [state, dispatch] = useReducer(reducer, initial);
return (
<StateContext value={state}>
<DispatchContext value={dispatch}>{children}</DispatchContext>
</StateContext>
);
}
4. The Guarded Hooks
function useAppState() {
const ctx = useContext(StateContext);
if (ctx === null) throw new Error('Missing Provider');
return ctx;
}
5. The Reader
function List() {
const items = useAppState();
return items.map((i) => <li key={i.id}>{i.text}</li>);
}
6. The Dispatcher
function AddButton() {
const dispatch = useAppDispatch();
return <button onClick={() => dispatch({ type: 'added' })}>Add</button>;
}
7. Combined Component
function Item({ item }) {
const dispatch = useAppDispatch();
return <li onClick={() => dispatch({ type: 'deleted', id: item.id })}>{item.text}</li>;
}
8. The App
<TasksProvider>
<AddTask />
<TaskList />
</TasksProvider>
9. Testing the Reducer
expect(reducer([], { type: 'added', item })).toEqual([item]);
10. Unknown Action
default: throw new Error(`Unknown action: ${action.type}`);
Visual
The State and Dispatch Flow
┌──────────────────────────────────────────────────────────────┐
│ STATE AND DISPATCH FLOW │
│ │
│ TasksProvider │
│ │ │
│ ├── useReducer(tasksReducer, initialTasks) │
│ │ → [tasks, dispatch] │
│ │ │
│ ├── <TasksContext value={tasks}> │
│ │ │ │
│ │ └── <TasksDispatchContext value={dispatch}> │
│ │ │ │
│ │ ├── AddTask │
│ │ │ useTasksDispatch() │
│ │ │ → dispatch only, no re-render │
│ │ │ │
│ │ └── TaskList │
│ │ useTasks() │
│ │ → re-renders on state change │
│ │ │ │
│ │ └── TaskItem │
│ │ useTasksDispatch() │
│ │ → dispatch only │
│ └── │
│ │
│ The dispatch context never changes. │
│ The state context changes on every dispatch. │
│ │
└──────────────────────────────────────────────────────────────┘
Why Split the Contexts
┌──────────────────────────────────────────────────────────────┐
│ WHY SPLIT THE CONTEXTS │
│ │
│ ONE CONTEXT: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ <AppContext value={{ tasks, dispatch }}> │ │
│ │ │ │
│ │ Every dispatch creates a new object. │ │
│ │ Every consumer re-renders. │ │
│ │ Including AddTask, which only dispatches. │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ TWO CONTEXTS: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ <TasksContext value={tasks}> │ │
│ │ <TasksDispatchContext value={dispatch}> │ │
│ │ │ │
│ │ tasks changes on every dispatch. │ │
│ │ dispatch never changes. │ │
│ │ │ │
│ │ TaskList reads tasks → re-renders. │ │
│ │ AddTask reads dispatch → never re-renders. │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ The split is the optimization. │
│ │
└──────────────────────────────────────────────────────────────┘
The Component Roles
┌──────────────────────────────────────────────────────────────┐
│ THE COMPONENT ROLES │
│ │
│ READER: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ function TaskList() { │ │
│ │ const tasks = useTasks(); │ │
│ │ return tasks.map(...); │ │
│ │ } │ │
│ │ │ │
│ │ Re-renders on every state change. │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ DISPATCHER: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ function AddTask() { │ │
│ │ const dispatch = useTasksDispatch(); │ │
│ │ return <button onClick={() => dispatch(...)}>; │ │
│ │ } │ │
│ │ │ │
│ │ Never re-renders because of a state change. │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ COMBINED: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ function TaskItem({ task }) { │ │
│ │ const dispatch = useTasksDispatch(); │ │
│ │ return <li onClick={...}>{task.text}</li>; │ │
│ │ } │ │
│ │ │ │
│ │ Reads the task from props, dispatches changes. │ │
│ │ Re-renders only when the parent passes new props. │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
The Guarded Hook
┌──────────────────────────────────────────────────────────────┐
│ THE GUARDED HOOK │
│ │
│ WITHOUT THE GUARD: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ function TaskList() { │ │
│ │ const tasks = useContext(TasksContext); │ │
│ │ return tasks.map(...); │ │
│ │ // tasks is null → TypeError: Cannot read │ │
│ │ // property 'map' of null │ │
│ │ } │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ WITH THE GUARD: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ function useTasks() { │ │
│ │ const context = useContext(TasksContext); │ │
│ │ if (context === null) { │ │
│ │ throw new Error('useTasks requires Provider'); │ │
│ │ } │ │
│ │ return context; │ │
│ │ } │ │
│ │ │ │
│ │ The error names the missing provider. │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Pattern | useReducer + context |
| State context | Distributes the state |
| Dispatch context | Distributes the dispatch function |
| Dispatch stability | dispatch never changes identity |
| Reader | Calls useTasks; re-renders on state change |
| Dispatcher | Calls useTasksDispatch; never re-renders on state change |
| Guarded hook | Throws when the provider is missing |
| Reducer | Pure function; testable in isolation |
| Unknown action | default case throws |
| Context privacy | Export only the hooks, not the context objects |
Key takeaways:
- Combine
useReducerwith context to share state across the tree. The reducer centralizes the state transitions, and the context distributes the state and the dispatch function. Components read and dispatch without knowing where the state lives . - Split the state and dispatch into separate contexts. The state changes on every dispatch; the dispatch function never changes. A component that reads only the dispatch context never re-renders because of a state change. This is the key optimization of the pattern .
- The dispatch function is stable. React guarantees that
dispatchdoes not change identity between renders, so it is safe to place in a context. It does not need to be memoized, and it can be omitted from dependency arrays . - Guard the consuming hooks with a null check. The hook throws when the context is
null, which converts a silentundefinedinto a clear error. The error message names the hook and the missing provider, making the structural mistake visible . - Keep the context objects private. Export the provider and the hooks, not the context objects. Consumers cannot bypass the guard by calling
useContextdirectly, and the context object remains an implementation detail of the module . - The reducer is a pure function. It can be tested in isolation with no rendering. The
defaultcase throws, which makes an unknown action an immediate error. The state is always the result of the reducer, and the reducer never mutates the state . - The pattern divides components into three roles. The reader reads the state and re-renders on changes. The dispatcher calls
useTasksDispatchand never re-renders because of a state change. The combined component receives data as props and dispatches changes . - The pattern is more code than
useStatein a component. It requires a provider, a reducer, two contexts, and two hooks. The overhead pays off when the state is shared across many components, when the transitions are complex, or when the state needs to be tested independently of the UI .
Remember: The combination of useReducer and context is the standard pattern for application-level state management in React without a third-party library. The reducer centralizes the state logic, the provider owns the state, and the two contexts distribute the state and the dispatch function. The split between state and dispatch is the optimization: components that only dispatch never re-render, and components that read the state re-render only when the state changes. The guarded hooks make the provider requirement explicit, and the private context objects keep the pattern’s internals encapsulated. For state that is shared across many components, the pattern provides structure, performance, and testability that a single useState cannot.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!