Context with useReducer
useReducer and Context work together particularly well: useReducer centralizes how a piece of state changes over time into a single pure function, and Context makes both that state and the function that updates it available to any component in the tree, no matter how deeply nested, without threading props through every layer in between. Combined, they give you a small, dependency-free alternative to external state-management libraries such as Redux, built entirely out of hooks React already ships with. This lesson walks through wiring useReducer and useContext together, three progressively more realistic examples, and the mistakes and performance techniques you need to use the pattern well in real apps.
Overview / How it works
useReducer is an alternative to useState for state that has multiple sub-values or where the next state depends on complex logic derived from the previous one. Instead of scattering several setX calls across a component, you describe every possible state transition as a single reducer function: (state, action) => newState. Components don’t set state directly — they dispatch plain objects called actions (usually with a type field), and the reducer decides how the state should change in response.
Context solves a different problem: passing data through many layers of components without manually forwarding props at every level (“prop drilling”). createContext creates a Context object; a <Context.Provider value={...}> makes a value available to every component underneath it; and useContext(Context) reads that value from the nearest enclosing Provider.
On their own, each hook is useful. Together, they form a common pattern: call useReducer once in a Provider component, then pass both state and dispatch down through a Context. Any descendant — however deeply nested — can call useContext to read the current state or dispatch an action, without the Provider needing to know which components care.
Under the hood, nothing exotic happens. The Provider component is a normal function component that happens to call useReducer. When you dispatch an action, React calls your reducer with the current state and the action, gets back a new state, and — because that new state is a different value from before — schedules a re-render of the Provider component. React then re-renders the Provider and reconciles its output; because the value prop passed to Context.Provider has changed, React also re-renders every component that consumes that Context via useContext, regardless of where those components sit in the tree. Components that don’t consume the Context are unaffected. This is exactly the same render → reconcile → commit cycle used everywhere else in React; Context and reducers don’t bypass it, they just change *which* components get re-rendered and *why*.
One subtlety worth internalizing: dispatch itself is guaranteed by React to have a stable identity across re-renders — it never changes, so it’s safe to omit from dependency arrays. The state returned by useReducer, however, is a new value every time the reducer runs, which is exactly what triggers Context consumers to update.
Syntax
const MyContext = createContext(defaultValue);
function reducer(state, action) {
switch (action.type) {
case "someAction":
return { ...state, /* changes */ };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function MyProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<MyContext.Provider value={{ state, dispatch }}>
{children}
</MyContext.Provider>
);
}
function useMyContext() {
const context = useContext(MyContext);
if (context === null) {
throw new Error("useMyContext must be used within a MyProvider");
}
return context;
}
| Part | Purpose |
|---|---|
createContext(defaultValue) |
Creates the Context object. Pass null (or another sentinel) as the default so you can detect a missing Provider. |
reducer(state, action) |
A pure function that computes and returns the next state. Never mutates state or performs side effects. |
useReducer(reducer, initialState) |
Returns [state, dispatch]. dispatch(action) triggers a re-render with the reducer’s result. |
<MyContext.Provider value={...}> |
Makes value available to every descendant that calls useContext(MyContext). |
Custom hook (useMyContext) |
Wraps useContext, hides the raw Context object, and throws a clear error if used outside the Provider. |
Examples
Example 1: A counter with useReducer + Context
import { createContext, useContext, useReducer } from "react";
const CounterContext = createContext(null);
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:
throw new Error(`Unknown action type: ${action.type}`);
}
}
function CounterProvider({ children }) {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<CounterContext.Provider value={{ state, dispatch }}>
{children}
</CounterContext.Provider>
);
}
function useCounter() {
const context = useContext(CounterContext);
if (context === null) {
throw new Error("useCounter must be used within a CounterProvider");
}
return context;
}
function CounterDisplay() {
const { state } = useCounter();
return <p>Count: {state.count}</p>;
}
function CounterButtons() {
const { dispatch } = useCounter();
return (
<div>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
</div>
);
}
export default function App() {
return (
<CounterProvider>
<CounterDisplay />
<CounterButtons />
</CounterProvider>
);
}
Output:
Renders "Count: 0" plus three buttons: -, Reset, +.
Clicking + updates the text to "Count: 1".
Clicking - decrements it again; Reset sets it back to "Count: 0".
Neither CounterDisplay nor CounterButtons holds any state itself. They both call useCounter(), which reads from the same CounterContext, so dispatching an action in one component instantly updates what the other renders — this is the core benefit of pairing useReducer with Context: shared state without passing count and dispatch down as props.
Example 2: A todo list
import { createContext, useContext, useReducer, useState } from "react";
const TodoContext = createContext(null);
function todoReducer(todos, action) {
switch (action.type) {
case "ADD_TODO":
return [...todos, { id: action.id, text: action.text, done: false }];
case "TOGGLE_TODO":
return todos.map((todo) =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
);
case "DELETE_TODO":
return todos.filter((todo) => todo.id !== action.id);
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
function TodoProvider({ children }) {
const [todos, dispatch] = useReducer(todoReducer, []);
return (
<TodoContext.Provider value={{ todos, dispatch }}>
{children}
</TodoContext.Provider>
);
}
function useTodos() {
const context = useContext(TodoContext);
if (context === null) {
throw new Error("useTodos must be used within a TodoProvider");
}
return context;
}
function AddTodoForm() {
const { dispatch } = useTodos();
const [text, setText] = useState("");
function handleSubmit(e) {
e.preventDefault();
if (text.trim() === "") return;
dispatch({ type: "ADD_TODO", id: Date.now(), text });
setText("");
}
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="New todo"
/>
<button type="submit">Add</button>
</form>
);
}
function TodoList() {
const { todos, dispatch } = useTodos();
return (
<ul>
{todos.map((todo) => (
<li key={todo.id} className={todo.done ? "todo done" : "todo"}>
<span onClick={() => dispatch({ type: "TOGGLE_TODO", id: todo.id })}>
{todo.text}
</span>
<button onClick={() => dispatch({ type: "DELETE_TODO", id: todo.id })}>
Delete
</button>
</li>
))}
</ul>
);
}
export default function App() {
return (
<TodoProvider>
<AddTodoForm />
<TodoList />
</TodoProvider>
);
}
This renders an input with an Add button and an empty list. Typing “Buy milk” and submitting appends a list item with a Delete button; clicking the item’s text toggles its done class via TOGGLE_TODO, and Delete removes it via DELETE_TODO. Notice that every state transition — add, toggle, delete — lives in one todoReducer function, and every list item still gets a stable key, just as it would with plain useState.
Example 3: Splitting state and dispatch contexts for performance
import { createContext, useContext, useReducer, useMemo } from "react";
const CartStateContext = createContext(null);
const CartDispatchContext = createContext(null);
function cartReducer(state, action) {
switch (action.type) {
case "ADD_ITEM": {
const existing = state.items.find((item) => item.id === action.item.id);
if (existing) {
return {
items: state.items.map((item) =>
item.id === action.item.id
? { ...item, quantity: item.quantity + 1 }
: item
),
};
}
return { items: [...state.items, { ...action.item, quantity: 1 }] };
}
case "REMOVE_ITEM":
return { items: state.items.filter((item) => item.id !== action.id) };
case "CLEAR_CART":
return { items: [] };
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
const memoizedState = useMemo(() => state, [state]);
return (
<CartStateContext.Provider value={memoizedState}>
<CartDispatchContext.Provider value={dispatch}>
{children}
</CartDispatchContext.Provider>
</CartStateContext.Provider>
);
}
function useCartState() {
const context = useContext(CartStateContext);
if (context === null) {
throw new Error("useCartState must be used within a CartProvider");
}
return context;
}
function useCartDispatch() {
const context = useContext(CartDispatchContext);
if (context === null) {
throw new Error("useCartDispatch must be used within a CartProvider");
}
return context;
}
function CartTotal() {
const { items } = useCartState();
const totalItems = items.reduce((sum, item) => sum + item.quantity, 0);
return <p>Items in cart: {totalItems}</p>;
}
function AddToCartButton({ product }) {
const dispatch = useCartDispatch();
return (
<button onClick={() => dispatch({ type: "ADD_ITEM", item: product })}>
Add {product.name}
</button>
);
}
export default function App() {
const mug = { id: 1, name: "Mug" };
return (
<CartProvider>
<CartTotal />
<AddToCartButton product={mug} />
</CartProvider>
);
}
This renders “Items in cart: 0” and a button reading “Add Mug”. Clicking it dispatches ADD_ITEM; since the mug isn’t in the cart yet, the reducer adds it with quantity: 1 and CartTotal re-renders to show “Items in cart: 1”. Clicking again finds the existing item and increments its quantity instead of duplicating it, showing “Items in cart: 2”. Crucially, AddToCartButton never re-renders when the cart contents change — it only reads CartDispatchContext, whose value (the dispatch function) never changes identity between renders. Splitting one Context into a state Context and a dispatch Context is a standard technique once an app has many components that only need to dispatch actions.
How it works step by step
On mount: React renders the Provider component, which calls useReducer to get the initial state and a stable dispatch function, then renders Context.Provider with that data as its value. React renders every descendant, and each one that calls useContext subscribes to that Context and reads the current value.
On dispatch: a descendant calls dispatch(action). React looks up the reducer tied to that useReducer call, invokes it with the current state and the action, and receives a new state. React schedules a re-render of the Provider component with this new state. During that re-render, the Context.Provider receives a new value; React compares it (by reference, using Object.is) to the previous value, and because it’s different, it re-renders every subscribed consumer, propagating the update down the tree — regardless of how deeply nested those consumers are, and independent of whether the components in between them re-render.
On unmount: when the Provider component (or an ancestor of it) is removed from the tree, React discards its reducer state entirely — there is nothing to persist unless you explicitly save it elsewhere (e.g. localStorage or a server). Consumers that were reading the Context are unmounted along with it if they were descendants, or simply stop reading it if the Provider was conditionally removed while consumers remain (in which case they’d fall back to the nearest remaining Provider, or the defaultValue passed to createContext).
Common Mistakes
Mistake 1: Mutating state inside the reducer
function todoReducer(todos, action) {
switch (action.type) {
case "ADD_TODO":
todos.push({ id: action.id, text: action.text, done: false });
return todos;
default:
return todos;
}
}
push mutates the existing array in place and then returns the very same reference. React compares the previous and next state with Object.is; because the reference didn’t change, React may skip re-rendering components that depend on it, and any other code holding a reference to the “old” state array sees it mutated too. Always build a new array or object:
function todoReducer(todos, action) {
switch (action.type) {
case "ADD_TODO":
return [...todos, { id: action.id, text: action.text, done: false }];
default:
return todos;
}
}
Mistake 2: Not guarding against a missing Provider
function useCounter() {
return useContext(CounterContext);
}
function CounterButtons() {
const { dispatch } = useCounter();
return <button onClick={() => dispatch({ type: "increment" })}>+</button>;
}
If CounterButtons is ever rendered outside a CounterProvider, useContext returns the Context’s default value (null, as declared with createContext(null)), and destructuring dispatch off of null throws a confusing error far from its real cause. Add an explicit check in the custom hook so the failure points at the actual problem:
function useCounter() {
const context = useContext(CounterContext);
if (context === null) {
throw new Error("useCounter must be used within a CounterProvider");
}
return context;
}
Mistake 3: One combined context causing unnecessary re-renders
function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
}
Every time state changes, this creates a brand-new { state, dispatch } object, so every component calling useContext(CartContext) re-renders — including ones that only dispatch actions and never read state. In a small app this is harmless; in a larger one with many consumers, it adds up. Split into a state Context and a dispatch Context (as in Example 3), or at minimum memoize the value with useMemo.
Best Practices
- Keep the reducer a pure function: no side effects, no direct mutation, only compute and return a new state.
- Export a custom hook (
useCounter,useCart, …) from the same file as the Provider so components never importuseContextor the raw Context object directly. - Throw a descriptive error in the custom hook when the context value is
null, so a missing Provider fails loudly and close to the mistake. - For state that updates often in a larger app, split the Provider into a state Context and a dispatch Context so components that only dispatch don’t re-render on every state change.
- Use an object of action type constants instead of hardcoded strings once a reducer has more than a handful of cases, to avoid silent typos.
- Co-locate the Context, reducer, and Provider for one feature in a single file (for example
CartContext.jsx) to keep that state logic self-contained. - Scope contexts to the feature or domain that actually needs them rather than putting all app state in one giant global reducer and Context.
- When the Context value is an object literal, wrap it in
useMemoso it only changes identity when its real dependencies change.
Practice Exercises
- Build a
ThemeContextbacked byuseReducerwith a reducer that handles"light"and"dark"actions, and consume it from two separate components (one that displays the current theme, one with a button that dispatches the toggle). - Extend the Example 2 todo list with an
EDIT_TODOaction that changes an existing todo’s text, and an input that appears next to a todo while it’s being edited. - Take the Example 3 cart reducer and add an
UPDATE_QUANTITYaction that increments or decrements a specific item’s quantity (removing the item if quantity reaches zero), then display the total price usinguseCartState.
Summary
useReducercentralizes related state transitions into one pure function, which scales better than several separateuseStatecalls once state logic gets complex.- Combining
useReducerwith Context shares both the state and thedispatchfunction with any descendant component, avoiding prop drilling. - Wrap the
useReducercall andContext.Providerin a dedicated Provider component, and expose a custom hook (with a null check) for consuming it. - React re-renders a Context consumer whenever the Provider’s
valuechanges identity — split state and dispatch into separate contexts, or memoize the value, to avoid unnecessary re-renders. - Reducers must stay pure and never mutate state directly — always return new objects or arrays.
- This pattern is a lightweight, dependency-free alternative to external state-management libraries for small-to-medium apps.
