Skip to content

useReducer Patterns

useState is perfect for independent pieces of state. But when several values change together in response to the same events — and the next state depends on the current state and an action — a reducer consolidates that logic into one pure function.

// The reducer is a pure function: (state, action) => nextState.
function tasksReducer(state, action) {
switch (action.type) {
case "added":
return [...state, { id: action.id, text: action.text, done: false }];
case "toggled":
return state.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
case "deleted":
return state.filter((t) => t.id !== action.id);
default:
throw new Error("unknown action: " + action.type);
}
}
function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, []);
// You describe WHAT happened (an action); the reducer decides the next state.
return <button onClick={() => dispatch({ type: "added", id: 1, text: "Learn" })}>Add</button>;
}

useReducer returns the current state and a dispatch function. You don’t compute the next state at the call site — you dispatch an action describing what happened, and the reducer computes the result in one place.

flowchart TB
  subgraph a["Many setState"]
    e1["event"] --> s1["setX"]
    e1 --> s2["setY"]
    e1 --> s3["setZ (logic scattered)"]
  end
  subgraph b["One reducer"]
    e2["event"] --> d["dispatch(action)"] --> r["reducer: all transition logic here"]
  end
Scattered setState vs one reducer

Reach for useReducer over useState when:

  • State transitions are complex — many fields update together, or the next value depends on the previous in non-trivial ways.
  • You want the logic in one testable place — a reducer is a plain function you can unit-test with no React at all: reducer(state, action) in, nextState out.
  • Actions document intentdispatch({ type: "checkout_failed" }) reads better than a pile of setters, and gives you a log of what happened.

Reducer purity is the same rule as render: given the same state and action, return the same next state, with no side effects and no mutation (return new objects/arrays).

Context + reducer: app state without a library

Section titled “Context + reducer: app state without a library”

Combine the two tools from this module and you get lightweight app-wide state management with zero dependencies: a reducer holds the logic, and Context delivers the state and dispatch down the tree.

const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null); // split: state vs dispatch
function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, []);
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>{children}</TasksDispatchContext>
</TasksContext>
);
}
// Anywhere below: read state and dispatch actions.
function useTasks() { return useContext(TasksContext); }
function useTasksDispatch() { return useContext(TasksDispatchContext); }

Splitting state and dispatch into two contexts means components that only dispatch (never read the list) don’t re-render when the list changes — dispatch is stable for the component’s lifetime. This pattern covers a surprising amount of “I need Redux” cases without any library.

What is a reducer?
When should you prefer useReducer over useState?
In the context + reducer pattern, why split state and dispatch into two contexts?
What must a reducer NOT do?