With useState, the logic that changes state lives in your event handlers. For a small component that is perfect. But as a component grows, the same state may be updated by many handlers in many ways: add an item, remove it, edit it, clear all, mark one done. The rules for how the state changes get scattered across the file. useReducer gathers them into one function, so you can read them in one place.
The idea in one example
First a counter, written both ways. With useState:
import { useState } from "react";
function App() {
const [count, setCount] = useState(0);
return (
<div>
<button className="dec" onClick={() => setCount(count - 1)}>-</button>
<span> {count} </span>
<button className="inc" onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
export default App;- 1 +And with useReducer:
import { useReducer } from "react";
function reducer(state, action) {
switch (action.type) {
case "increment":
return state + 1;
case "decrement":
return state - 1;
default:
throw new Error("Unknown action: " + action.type);
}
}
function App() {
const [count, dispatch] = useReducer(reducer, 0);
return (
<div>
<button className="dec" onClick={() => dispatch({ type: "decrement" })}>-</button>
<span> {count} </span>
<button className="inc" onClick={() => dispatch({ type: "increment" })}>+</button>
</div>
);
}
export default App;- 1 +Both give 1 after the test. The reducer version is longer for something this small, but it shows the three parts:
- State and a
dispatchfunction:useReducer(reducer, initialState)returns the current state anddispatch. - Actions: event handlers no longer say how to change state. They report what happened by dispatching an action, a plain object describing it. By convention it has a
typestring, plus any data the change needs. - The reducer: one pure function
(state, action) => nextStatethat React calls with the current state and the action. It decides what the next state is.
The name comes from Array.prototype.reduce: a reducer takes the accumulated result and the next item, and returns a new result. Here the "items" are the actions that happened.
A realistic reducer: a task list
A list with several operations shows where a reducer shines. Notice how every rule about changing tasks now sits in one function:
import { useReducer, useState } from "react";
let nextId = 3;
const initialTasks = [
{ id: 1, text: "Learn reducers", done: false },
{ id: 2, text: "Write a task list", done: false },
];
function tasksReducer(tasks, action) {
switch (action.type) {
case "added":
return [...tasks, { id: action.id, text: action.text, done: false }];
case "toggled":
return tasks.map(task => (task.id === action.id ? { ...task, done: !task.done } : task));
case "removed":
return tasks.filter(task => task.id !== action.id);
case "cleared":
return tasks.filter(task => !task.done);
default:
throw new Error("Unknown action: " + action.type);
}
}
function App() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
const [text, setText] = useState("");
function handleAdd() {
if (text.trim() === "") return;
dispatch({ type: "added", id: nextId++, text: text.trim() });
setText("");
}
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} placeholder="New task" />
<button className="add" onClick={handleAdd}>Add</button>
<ul>
{tasks.map(task => (
<li key={task.id}>
<input type="checkbox" checked={task.done} onChange={() => dispatch({ type: "toggled", id: task.id })} />{" "}
{task.text}{" "}
<button className="remove" onClick={() => dispatch({ type: "removed", id: task.id })}>x</button>
</li>
))}
</ul>
<button className="clear" onClick={() => dispatch({ type: "cleared" })}>Clear completed</button>
<p>{tasks.length} tasks, {tasks.filter(t => t.done).length} done</p>
</div>
);
}
export default App;Add
Buy milk x
Call Ravi x
Clear completed
2 tasks, 0 doneIn the test I added two tasks, ticked the first existing task, removed the second existing task, and pressed "Clear completed" (which removed the ticked one), leaving only the two new tasks. Read the handlers: each one is a single dispatch line saying what happened. All the meaning of the state sits in tasksReducer: to understand how tasks can change, you read one switch. (The text of the input is separate, ordinary useState, because it is a different concern. Mixing useState and useReducer in a component is normal.)
Rules for reducers
A reducer is called by React during rendering, so the rules of pure functions apply:
- Do not mutate the state. Return a new object or array (spread,
map,filter), as in the previous lessons. A mutated state gives the same reference, and React skips the update. - No side effects: no fetching, timers, random values,
localStorageorDate.now()inside it. If an action needs a generated id or the current time, create it in the event handler (likenextId++above) and put it in the action. Notice howidcame in with the action, so that the reducer stays predictable. - Always return a state. An unknown action should either return
stateunchanged or throw, so that typos are caught. Forgettingreturnin a case givesundefinedand wipes the state. - One action, one thing that happened. Describe user intent (
added,removed), not low-level steps (setTasks).
React in development calls your reducer twice (StrictMode) to catch impure code, which is one of several reasons rule 2 matters.
Testing a reducer without React
Since a reducer is a pure function, you can test it with plain JavaScript, with no components, no DOM and no rendering. This is one of the main benefits:
function tasksReducer(tasks, action) {
switch (action.type) {
case "added":
return [...tasks, { id: action.id, text: action.text, done: false }];
case "toggled":
return tasks.map(t => (t.id === action.id ? { ...t, done: !t.done } : t));
default:
return tasks;
}
}
const before = [{ id: 1, text: "A", done: false }];
const afterAdd = tasksReducer(before, { type: "added", id: 2, text: "B" });
const afterToggle = tasksReducer(afterAdd, { type: "toggled", id: 1 });
console.log("added:", afterAdd.length === 2, "original untouched:", before.length === 1);
console.log("toggled:", afterToggle[0].done === true, "second still open:", afterToggle[1].done === false);
function App() {
return <p>Open the Console panel below for the test results.</p>;
}
export default App;Open the Console panel below for the test results.
CONSOLE
added: true original untouched: true
toggled: true second still open: trueBoth checks print true, and the original array is unchanged. In a real project you would write these as test cases with a tool like Vitest (a later lesson). Easy testing is a strong reason to move complicated state logic into a reducer.
useState or useReducer?
useState |
useReducer |
|
|---|---|---|
| Code size | less at the start | more up front |
| Update logic | in each event handler | in one reducer function |
| Related values changing together | possible, but spread out | natural |
| Debugging | add logs to handlers | log every action in the reducer |
| Testing | through the component | the reducer alone |
| Good for | simple, independent values | complex state with many kinds of update |
A practical rule: start with useState. When you notice a component with many handlers all calling setThing(...) in related ways, or state updates that depend on the previous state in non-obvious ways, move to useReducer. Neither is better in general. You can even convert between them mechanically.
Reducers elsewhere
The same idea powers other tools. Redux is built entirely around reducers and actions, with one store for the whole app. The reduce array method, Elm and many state libraries use the pattern. Understanding useReducer makes these much easier to learn. And in the next lesson, you will combine useReducer with context to share state and dispatch with a whole tree of components without passing props through every level.
Common mistakes
- Mutating the state inside the reducer (
state.push(...),state.done = true). - Calling
fetch,Math.random()orDate.now()in the reducer. - Forgetting
returnorbreak/returnin aswitchcase, causing fall-through orundefined. - Dispatching with the wrong shape (
dispatch("added")instead ofdispatch({ type: "added" })). - Using
useReducerfor a single boolean, which is more code than it is worth. - Putting everything in one giant reducer; split state by concern, or by feature.
Exercise
- Add an "edited" action that changes a task's text, with an edit button next to each task.
- Add a "toggled all" action that marks every task done if any is open, or open if all are done.
- Convert your counter from the state lessons to a reducer with
increment,decrementandreset, plus asetaction that carries a number. - Write a console test (like the one above) for the "cleared" action.
Key points
- useReducer(reducer, initialState) returns [state, dispatch]; the reducer is a pure function (state, action) => newState.
- Event handlers call dispatch with a small description of what happened, such as { type: 'added', text }, and the reducer decides how state changes.
- A reducer keeps all update logic for a piece of state in one place, and it can be tested as a plain function.
- Use useState for simple independent values and useReducer when many handlers update the same state in related ways.