More React Hooks · Lesson 17 of 31
React useReducer Hook
When a component has several pieces of state that change together – a cart with items, totals and a coupon, or a form with a status, errors and data – scattered setX calls get hard to follow. useReducer moves all the update logic into one pure function, the reducer, and components just describe what happened by dispatching actions.
In this lesson
- Reducers, actions and dispatch
- A shopping cart built with useReducer
- Choosing between useState and useReducer
- The connection to Redux
The idea
How useReducer updates state
- dispatchan event handler calls dispatch({ type: "added", item })
- reducerReact calls reducer(currentState, action)
- new statethe reducer returns the next state – a new object, never mutated
- renderthe component re-renders with it
import { useReducer } from "react";
function counterReducer(state, action) { // (state, action) => newState
switch (action.type) {
case "increment": return { count: state.count + action.by };
case "decrement": return { count: Math.max(0, state.count - action.by) };
case "reset": return { count: 0 };
default: throw new Error(`Unknown action: ${action.type}`);
}
}
export default function App() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 }); // (reducer, initial state)
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: "increment", by: 1 })}>+1</button>
<button onClick={() => dispatch({ type: "increment", by: 5 })}>+5</button>
<button onClick={() => dispatch({ type: "decrement", by: 1 })}>−1</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</div>
);
}
Count: 0 [+1] [+5] [−1] [Reset] › click “+5” Count: 5 [+1] [+5] [−1] [Reset] › click “+1” Count: 6 [+1] [+5] [−1] [Reset] › click “−1” Count: 5 [+1] [+5] [−1] [Reset] › click “Reset” Count: 0 [+1] [+5] [−1] [Reset]
A shopping cart
import { useReducer } from "react";
const products = [
{ id: "kb", name: "Keyboard", price: 3499 },
{ id: "ms", name: "Mouse", price: 899 },
];
const initialCart = { items: [], coupon: null };
function cartReducer(cart, action) {
switch (action.type) {
case "added": {
const existing = cart.items.find((i) => i.id === action.product.id);
const items = existing
? cart.items.map((i) => (i.id === action.product.id ? { ...i, qty: i.qty + 1 } : i))
: [...cart.items, { ...action.product, qty: 1 }];
return { ...cart, items };
}
case "removed":
return { ...cart, items: cart.items.filter((i) => i.id !== action.id) };
case "couponApplied":
return { ...cart, coupon: action.code === "LEARN10" ? { code: action.code, off: 0.1 } : null };
case "cleared":
return initialCart;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
export default function App() {
const [cart, dispatch] = useReducer(cartReducer, initialCart);
const subtotal = cart.items.reduce((sum, i) => sum + i.price * i.qty, 0);
const total = Math.round(subtotal * (1 - (cart.coupon?.off ?? 0)));
return (
<div>
{products.map((p) => (
<button key={p.id} onClick={() => dispatch({ type: "added", product: p })}>Add {p.name}</button>
))}
<ul>
{cart.items.map((i) => (
<li key={i.id}>
{i.name} × {i.qty} = ₹{i.price * i.qty}{" "}
<button onClick={() => dispatch({ type: "removed", id: i.id })}>Remove {i.name}</button>
</li>
))}
</ul>
<button onClick={() => dispatch({ type: "couponApplied", code: "LEARN10" })}>Apply LEARN10</button>
<p>Total: ₹{total}{cart.coupon && ` (${cart.coupon.code} applied)`}</p>
</div>
);
}
[Add Keyboard] [Add Mouse] [Apply LEARN10] Total: ₹0 › click “Add Keyboard” [Add Keyboard] [Add Mouse] • Keyboard × 1 = ₹3499 [Remove Keyboard] [Apply LEARN10] Total: ₹3499 › click “Add Mouse” [Add Keyboard] [Add Mouse] • Keyboard × 1 = ₹3499 [Remove Keyboard] • Mouse × 1 = ₹899 [Remove Mouse] [Apply LEARN10] Total: ₹4398 › click “Add Mouse” [Add Keyboard] [Add Mouse] • Keyboard × 1 = ₹3499 [Remove Keyboard] • Mouse × 2 = ₹1798 [Remove Mouse] [Apply LEARN10] Total: ₹5297 › click “Apply LEARN10” [Add Keyboard] [Add Mouse] • Keyboard × 1 = ₹3499 [Remove Keyboard] • Mouse × 2 = ₹1798 [Remove Mouse] [Apply LEARN10] Total: ₹4767 (LEARN10 applied) › click “Remove Keyboard” [Add Keyboard] [Add Mouse] • Mouse × 2 = ₹1798 [Remove Mouse] [Apply LEARN10] Total: ₹1618 (LEARN10 applied)
Why this scales
- All the ways the cart can change are listed in one place.
- Event handlers stay tiny: they say what happened ("added"), not how to update.
- The reducer is a pure function, so you can unit-test it without rendering anything:
cartReducer(initialCart, { type: "added", product }).
useState or useReducer?
| Prefer useState when… | Prefer useReducer when… |
|---|---|
| state is a few independent values | several values change together |
| updates are simple (set, toggle) | the next state depends on the previous one in many ways |
| logic is short and local | you want the update logic testable and in one place |
They're interchangeable – useState is implemented with a reducer internally – so choose whichever makes the code clearer. Combined with Context (next chapter), useReducer gives you simple app-wide state without a library.
useReducer and Redux
Redux uses the same idea: a store, actions and a reducer. Modern Redux code is written with Redux Toolkit, whose createSlice generates actions and lets you write reducers in a "mutating" style (using Immer). If you understand useReducer, you already understand the core of Redux.
Practice
Try it yourself
Write a reducer for a request status with actions "started", "succeeded" (with data) and "failed" (with error).
Show solution
import { useReducer } from "react"; function requestReducer(state, action) { switch (action.type) { case "started": return { status: "loading", data: null, error: null }; case "succeeded": return { status: "success", data: action.data, error: null }; case "failed": return { status: "error", data: null, error: action.error }; default: throw new Error(action.type); } } export default function App() { const [state, dispatch] = useReducer(requestReducer, { status: "idle", data: null, error: null }); return ( <div> <p>Status: {state.status}{state.data && ` – ${state.data}`}{state.error && ` – ${state.error}`}</p> <button onClick={() => dispatch({ type: "started" })}>Start</button> <button onClick={() => dispatch({ type: "succeeded", data: "12 courses" })}>Succeed</button> <button onClick={() => dispatch({ type: "failed", error: "Timeout" })}>Fail</button> </div> ); }Status: idle [Start] [Succeed] [Fail] › click “Start” Status: loading [Start] [Succeed] [Fail] › click “Succeed” Status: success – 12 courses [Start] [Succeed] [Fail] › click “Fail” Status: error – Timeout [Start] [Succeed] [Fail]
With one state object, impossible combinations – like loading and showing an error – can't happen.
Quick check
What does a reducer receive and return?
The current state and an action; it returns the next state.
Can a reducer call an API or mutate state?
No – it must be pure. Do side effects in handlers or effects.
What does dispatch do?
Sends an action to the reducer and schedules a re-render with the result.
Summary
- useReducer(reducer, initialState) returns [state, dispatch].
- Handlers dispatch actions describing what happened; the pure reducer computes the next state.
- Use it for related state with many transitions; it's the same model as Redux.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.