useReducer

Course 2 · Ch 3
useReducer
Centralizing every way a piece of state can change into a single, predictable function

The Kanban board and shopping cart projects both had state that could change in several distinct ways — add, remove, update quantity, move between columns — each handled by its own separate function calling setItems or setColumns with slightly different logic. As the number of "ways state can change" grows, that logic ends up scattered across many handlers. useReducer centralizes all of it into one place: a single function describing every possible state transition.

A Reducer Is Just a Function

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; } }

A reducer takes the current state and an action describing what happened, and returns the new state — nothing more. It's a plain function, with no React-specific code inside it at all; every possible transition lives in one readable place instead of being spread across separate handleIncrement/handleDecrement/handleReset functions.

useReducer in a Component

import { useReducer } from "react"; function Counter() { const [state, dispatch] = useReducer(counterReducer, { count: 0 }); return ( <div> <p>{state.count}</p> <button onClick={() => dispatch({ type: "increment" })}>+1</button> <button onClick={() => dispatch({ type: "decrement" })}>-1</button> <button onClick={() => dispatch({ type: "reset" })}>Reset</button> </div> ); }

useReducer(reducer, initialState) returns [state, dispatch]state is read exactly like useState's value, but instead of a setter, dispatch sends an action object to the reducer, which decides what the new state should be. No button handler needs to know how to update the count — each one just describes what happened ("increment"), leaving the actual logic entirely inside the reducer.

Actions with a Payload

function todosReducer(state, action) { switch (action.type) { case "add": return [...state, { id: action.payload.id, text: action.payload.text, done: false }]; case "toggle": return state.map((todo) => todo.id === action.payload.id ? { ...todo, done: !todo.done } : todo ); case "remove": return state.filter((todo) => todo.id !== action.payload.id); default: return state; } } // dispatching with extra information attached: dispatch({ type: "toggle", payload: { id: 3 } });

payload is just a convention — a place to attach whatever extra information a particular action needs (which todo's id to toggle or remove, the text for a new one). Project 1's todo list scattered this same add/toggle/remove logic across three separate state-updating functions; written as a reducer, all three transitions live together, each one still using the same immutable-update rules (spread, .map(), .filter()) from Fundamentals Chapter 3 and Project 1 — just consolidated into a single function instead of three.

Pairing useReducer with Context

useReducer and Context combine naturally for global state: the reducer's state becomes the context's value, and dispatch itself can be exposed through context too, letting any deeply nested component dispatch an action without ever needing the state-updating logic passed down to it as props. This combination is genuinely how some real apps implement global state without reaching for an external library at all — a more powerful version of the CartProvider pattern from Project 6, with one centralized reducer instead of several separate updater functions.

A reducer must stay pure
A reducer should never mutate its state argument directly, call an API, set a timer, or do anything else with a side effect — given the same state and action, it must always return the same result, with no exceptions. It's also easy to forget the default case in the switch — without one, dispatching an unrecognized action type returns undefined instead of the unchanged state, silently wiping out everything.
SituationReach for...
One or two simple, independent valuesuseState
Several related values that change together, in many distinct waysuseReducer
The same reducer state needed deep in the tree, widelyuseReducer + Context

Coding Challenges

Challenge 1

Build a Counter component using useReducer with "increment", "decrement", and "reset" action types, matching the chapter's example exactly.

📄 View solution
Challenge 2

Refactor a todo list (add, toggle complete, remove) to use a single useReducer instead of separate useState-driven handlers, using "add"/"toggle"/"remove" action types with a payload carrying the relevant id/text.

📄 View solution
Challenge 3

Build a NotificationContext using useReducer + Context together, supporting "show" (adds a message) and "dismiss" (removes one by id) actions, consumed by two unrelated components — one that triggers a notification, one that displays the current list of them.

📄 View solution

Chapter 3 Quick Reference

  • A reducer is a pure function: (state, action) => newState
  • useReducer(reducer, initialState) returns [state, dispatch]
  • dispatch({ type, payload }) — describes what happened, not how to update state
  • Centralizes many related state transitions in one place, instead of scattering them across handlers
  • Pairs naturally with Context for global state, without an external library
  • A reducer must stay pure — no mutation, no side effects, and always include a default case
  • Next chapter: custom hooks — extracting reusable logic of your own, the way useTheme did in Chapter 2