Himanshu SharmaSoftware engineer
Let’s talk

React · Lesson 27 of 38

State logic with useReducer

Manage complex React state with useReducer: write a pure reducer, dispatch actions, build a task reducer, test it without React and compare it with useState.

20 minSharing state and performanceUpdated 24 October 2026

By the end of this lessonMove scattered state updates into a single reducer, dispatch descriptive actions, and decide between useState and useReducer.

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:

jsx
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;
result
- 1 +

And with useReducer:

jsx
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;
result
- 1 +

Both give 1 after the test. The reducer version is longer for something this small, but it shows the three parts:

  1. State and a dispatch function: useReducer(reducer, initialState) returns the current state and dispatch.
  2. 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 type string, plus any data the change needs.
  3. The reducer: one pure function (state, action) => nextState that 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:

jsx
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;
result
Add
 Buy milk x
 Call Ravi x
Clear completed
2 tasks, 0 done

In 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:

  1. 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.
  2. No side effects: no fetching, timers, random values, localStorage or Date.now() inside it. If an action needs a generated id or the current time, create it in the event handler (like nextId++ above) and put it in the action. Notice how id came in with the action, so that the reducer stays predictable.
  3. Always return a state. An unknown action should either return state unchanged or throw, so that typos are caught. Forgetting return in a case gives undefined and wipes the state.
  4. 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:

jsx
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;
result
Open the Console panel below for the test results.
CONSOLE
added: true original untouched: true
toggled: true second still open: true

Both 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() or Date.now() in the reducer.
  • Forgetting return or break/return in a switch case, causing fall-through or undefined.
  • Dispatching with the wrong shape (dispatch("added") instead of dispatch({ type: "added" })).
  • Using useReducer for 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

  1. Add an "edited" action that changes a task's text, with an edit button next to each task.
  2. Add a "toggled all" action that marks every task done if any is open, or open if all are done.
  3. Convert your counter from the state lessons to a reducer with increment, decrement and reset, plus a set action that carries a number.
  4. 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.

Check yourself

1. What does dispatch do?
2. Which statement about a reducer is true?
3. What should a reducer return for an action type it does not recognise?
4. When is useReducer a better fit than useState?