🎓 What You Will Learn
- Why useReducer: when
useStatestops scaling for related state - Reducers & dispatch:
(state, action) => newState, and firing actions - Actions with payloads: passing data along with an action type
- useState vs useReducer: a clear decision rule, not a vibe
- Lazy initialization: computing expensive initial state only once
- useReducer + useContext: the mini-Redux pattern for global state
Why useReducer Exists
useState is great for one independent value. It gets messy once a component has several related pieces of state that change together — a form with loading/error/data, or a cart with items, totals, and discounts. You end up with five useState calls and update logic scattered across multiple handlers.
useReducer centralizes that logic: one function decides how state changes for every possible action, and components just describe what happened — not how the state should be recomputed.
Reducer Basics — dispatch and Actions
A reducer is a pure function: (state, action) => newState. useReducer(reducer, initialState) returns the current state and a dispatch function. Calling dispatch(action) runs the reducer and re-renders with whatever it returns.
0
Math.random()). That's what makes state changes easy to trace and test.Actions with a Payload
Most real actions carry data along with the type — which todo to toggle, what text to add. Convention: keep type as a string and attach whatever extra fields the reducer needs.
state and action — no API calls inside a case. Trigger side effects in the component (usually inside a useEffect), then dispatch the result.useState vs. useReducer — How to Decide
There's no hard rule, but a good heuristic: if you can describe your state changes as "increment this number" or "toggle this boolean,"useState is simpler. If you find yourself writing "and also update this other field" inside multiple handlers, that's the smell that means useReducer will pay off.
| Signal | Reach for |
|---|---|
| A single independent value (text, boolean, number) | useState |
| Several values that always change together | useReducer |
| The next state depends on the previous state in a complex way | useReducer |
| Many handlers each doing 1–2 setState calls | useReducer |
| You want state transitions to be traceable/testable in isolation | useReducer |
Lazy Initialization
If computing the initial state is expensive (parsing localStorage, building a big default object), pass a third argument — an init function — so it only runs once, not on every render.
function init(initialCount) {
// Runs ONCE, not on every render
return { count: initialCount, history: [] };
}
function reducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1, history: [...state.history, "inc"] };
default:
return state;
}
}
// Third argument is the initial value passed into init()
const [state, dispatch] = useReducer(reducer, 0, init);useReducer + useContext — The Mini-Redux Pattern
Pair a reducer with context and you get a small, dependency-free global store: dispatch is stable across renders (React guarantees it never changes), so any component anywhere in the tree can read state or fire actions without prop drilling.
Products
Cart
Cart is empty.
Common Mistakes
// ❌ BAD: mutates the array in place
case "toggle":
state[index].done = !state[index].done;
return state;
// ✅ GOOD: returns a new array
case "toggle":
return state.map((t, i) =>
i === index ? { ...t, done: !t.done } : t
);// ❌ BAD: an unknown action silently returns undefined
function reducer(state, action) {
switch (action.type) {
case "add": return [...state, action.item];
}
}
// ✅ GOOD: always fall back to the current state
function reducer(state, action) {
switch (action.type) {
case "add": return [...state, action.item];
default: return state;
}
}// ❌ BAD: API calls don't belong in a reducer
case "save":
fetch("/api/save", { method: "POST", body: JSON.stringify(state) });
return state;
// ✅ GOOD: dispatch describes intent; effects live in a useEffect
useEffect(() => {
if (state.shouldSave) {
fetch("/api/save", { method: "POST", body: JSON.stringify(state) });
}
}, [state.shouldSave]);Best Practices
"todo/added" beats "setTodos"Pattern Reference
| Piece | Purpose |
|---|---|
| reducer(state, action) | Pure function computing the next state |
| useReducer(reducer, initial) | Returns [state, dispatch] |
| dispatch(action) | Triggers the reducer and a re-render |
| useReducer(reducer, arg, init) | Lazy initialization — init(arg) runs once |
| Context + useReducer | A dependency-free global store (mini-Redux) |
Practice Project
Build a small quiz app reducer that:
- Tracks
currentQuestion,score, andanswersin one state object - Handles
"answer/submitted"(advance the question, update the score) and"quiz/restarted"actions - Uses lazy initialization to shuffle the question order once
- Wraps it in context so a progress bar and the question screen both read from the same reducer
What's Next?
- useContext: revisit the Provider pattern this post builds on
- Custom Hooks: extract a
useCart()hook that wraps the reducer + context - useRef: persistent values that don't trigger re-renders
- State Management: when to graduate from this pattern to Zustand or Redux Toolkit
Keep practicing! 💪 Once state stops being "one value" and starts being "a system of related values," reaching for useReducer instead of five useState calls will make your components far easier to reason about.