useReducer
As a component grows, its event handlers multiply: add an item, remove it, change the quantity, apply a coupon, clear everything. Each handler updates state in its own way, and the logic ends up scattered across the component. The useReducer hook lets you collect all of those updates into one function, called a reducer, that lives outside the component.
In this lesson you will write a reducer, dispatch actions to it, see why reducers are easy to test, and learn how to choose between useState and useReducer.
From handlers to actions
With useState, each handler says how to change the state. With a reducer, handlers only say what happened, by dispatching an action: a plain object that describes the event, usually with a type field. The reducer then decides how the state changes.
// useState: the handler contains the update logic
setItems(items.filter((i) => i.id !== id));
// useReducer: the handler only reports the event
dispatch({ type: "removed", id });A reducer is a function (state, action) => newState. It receives the current state and the action, and returns the next state. Like rendering, it must be pure: no mutation, no network requests, no random values, just a new state calculated from its inputs.
A canteen order with useReducer
import { useReducer } from "react";
import { createRoot } from "react-dom/client";
const menu = [
{ id: "idli", name: "Idli (2 pcs)", price: 40 },
{ id: "dosa", name: "Masala dosa", price: 70 },
{ id: "coffee", name: "Filter coffee", price: 25 },
];
function orderReducer(order, action) {
switch (action.type) {
case "added": {
const qty = (order[action.id] ?? 0) + 1;
return { ...order, [action.id]: qty };
}
case "removed": {
const qty = (order[action.id] ?? 0) - 1;
if (qty <= 0) {
const { [action.id]: _gone, ...rest } = order;
return rest;
}
return { ...order, [action.id]: qty };
}
case "cleared":
return {};
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Canteen() {
const [order, dispatch] = useReducer(orderReducer, {});
const total = menu.reduce((sum, m) => sum + (order[m.id] ?? 0) * m.price, 0);
return (
<div>
<h3>Kochi college canteen</h3>
{menu.map((m) => (
<p key={m.id}>
{m.name} ₹{m.price}{" "}
<button onClick={() => dispatch({ type: "removed", id: m.id })}>-</button>{" "}
{order[m.id] ?? 0}{" "}
<button onClick={() => dispatch({ type: "added", id: m.id })}>+</button>
</p>
))}
<p><strong>Total: ₹{total}</strong></p>
<button onClick={() => dispatch({ type: "cleared" })}>Clear order</button>
</div>
);
}
createRoot(document.getElementById("root")).render(<Canteen />);useReducer(orderReducer, {}) takes the reducer and the initial state, and returns the current state and a dispatch function. When you call dispatch(action), React calls orderReducer(currentState, action), stores the result and re-renders, just as a setter would.
The switch statement is the usual way to write reducers: one case per action type. Wrapping each case in braces { } lets you declare const variables inside it. The default case throws for unknown types, so a typo such as "add" instead of "added" fails loudly instead of being ignored. Name action types after what happened ("added", "cleared"), not after commands.
Reducers are easy to test
Because a reducer is an ordinary pure function that knows nothing about React, you can call it directly and check the results. This is a big advantage when the logic is important, such as money calculations:
function walletReducer(wallet, action) {
switch (action.type) {
case "credited":
return { ...wallet, balance: wallet.balance + action.amount, history: [...wallet.history, `+${action.amount}`] };
case "debited":
if (action.amount > wallet.balance) {
return { ...wallet, error: "Insufficient balance" };
}
return { ...wallet, balance: wallet.balance - action.amount, error: "", history: [...wallet.history, `-${action.amount}`] };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
let wallet = { balance: 500, history: [], error: "" };
wallet = walletReducer(wallet, { type: "credited", amount: 1000 });
wallet = walletReducer(wallet, { type: "debited", amount: 299 });
wallet = walletReducer(wallet, { type: "debited", amount: 5000 });
console.log("Balance:", wallet.balance);
console.log("History:", wallet.history.join(", "));
console.log("Error:", wallet.error);Balance: 1201 History: +1000, -299 Error: Insufficient balance
In a real project you would write these checks as automated tests, covered in Testing with Vitest. Notice that the failed debit is not an exception: it returns a new state with an error message the UI can show.
useState or useReducer?
Both hooks store state; useState is in fact built on the same mechanism. Choose based on how the updates look:
| Situation | Better choice |
|---|---|
| A few independent values (a toggle, an input) | useState |
| Many related values updated together | useReducer |
| Many different ways to update the same data | useReducer |
| Next state depends on complex rules you want to test | useReducer |
| You want event handlers to stay tiny | useReducer |
You can mix both in one component. Converting from one to the other later is straightforward, so do not worry about choosing perfectly at the start.
Advanced: reducer plus context
For app-wide state, such as a cart used on many pages, combine a reducer with Context. Put the state in one context and dispatch in another, so any component can read the cart or send actions without prop drilling:
const CartContext = createContext(null);
const CartDispatchContext = createContext(null);
function CartProvider({ children }) {
const [cart, dispatch] = useReducer(cartReducer, [], loadSavedCart);
return (
<CartContext value={cart}>
<CartDispatchContext value={dispatch}>{children}</CartDispatchContext>
</CartContext>
);
}
// Anywhere below: const dispatch = useContext(CartDispatchContext);
// dispatch({ type: "added", product });The third argument, loadSavedCart, is an optional init function: React calls loadSavedCart([]) once to build the initial state, which is useful for reading saved data. dispatch never changes between renders, so components that only send actions do not re-render when the cart changes.
Common mistakes
- Mutating state in the reducer.
state.items.push(x); return state;returns the same object, so nothing re-renders. Return new objects and arrays. - Side effects in the reducer. Fetching, logging to a server or
Math.random()make the reducer impure. Do them in event handlers or effects, then dispatch the result. - Forgetting to return. A
casewithoutreturnfalls through to the next case. - Reading state right after
dispatch. Like a setter,dispatchschedules an update; the variable keeps its old value in the current render.
Key points
useReducer(reducer, initialState)returns[state, dispatch].- A reducer
(state, action) => newStateis pure and holds all the update logic in one place. - Event handlers dispatch actions such as
{ type: "added", id }that describe what happened. - Reducers are plain functions, so they are easy to test without React.
- Prefer
useReducerwhen many related updates or complex rules act on the same state. - Put state and
dispatchin context to share a reducer across the app.
Exercise
Write a scoreReducer for a cricket scoreboard with state { runs, wickets, balls }. Support the actions "run" (with runs: 0 to 6), "wicket" and "reset". Every action except reset adds one ball, and no action may change the score once 10 wickets have fallen. Use it with useReducer and show the score as "Runs/Wickets (Overs)", where overs are written like 2.3 for 15 balls.
Show solution
import { useReducer } from "react";
import { createRoot } from "react-dom/client";
const start = { runs: 0, wickets: 0, balls: 0 };
function scoreReducer(score, action) {
if (action.type === "reset") return start;
if (score.wickets === 10) return score; // innings over
switch (action.type) {
case "run":
return { ...score, runs: score.runs + action.runs, balls: score.balls + 1 };
case "wicket":
return { ...score, wickets: score.wickets + 1, balls: score.balls + 1 };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Scoreboard() {
const [score, dispatch] = useReducer(scoreReducer, start);
const overs = `${Math.floor(score.balls / 6)}.${score.balls % 6}`;
return (
<div>
<h3>{score.runs}/{score.wickets} ({overs})</h3>
{[0, 1, 2, 4, 6].map((r) => (
<button key={r} onClick={() => dispatch({ type: "run", runs: r })}>{r}</button>
))}
<button onClick={() => dispatch({ type: "wicket" })}>Wicket</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</div>
);
}
createRoot(document.getElementById("root")).render(<Scoreboard />);All the cricket rules live in the reducer, so the buttons only describe what happened. Returning the same score object after 10 wickets means React skips the re-render.
Quick Quiz
1. What does a reducer function receive?
React calls reducer(state, action) and uses the returned value as the next state.
2. What should an event handler do when using useReducer?
Handlers dispatch actions; React runs the reducer with the current state.
3. Which of these is allowed inside a reducer?
Reducers must be pure: they calculate and return new state without side effects or mutation.
4. When is useReducer usually a better choice than useState?
A reducer gathers complex, related update logic into one testable function.
Learn React with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
