Skip to content

React Tutorial Advanced React Concepts

Reducers and the useReducer Hook in React

Lesson 116 of 156 2 min read By Deepak Paswan Updated Oct 10, 2026

Runnable lesson · press Try it on the examples to edit and run them here.

When a component has many pieces of state and many ways to change them, the update logic gets scattered across event handlers. A reducer collects all of it in one function, so you can see every possible change at a glance.

The three ideas

  • State: the current data.
  • Action: a small object describing what happened ({ type: "added", item }).
  • Reducer: a pure function (state, action) => newState that decides how state changes for each action.
App.jsx
JSX
const [state, dispatch] = useReducer(reducer, initialState);

dispatch(action) sends an action to the reducer. React calls reducer(currentState, action) and re-renders with the result.

A todo list with a reducer

App.jsx
JSX
function todosReducer(todos, action) {
  switch (action.type) {
    case "added":
      return [...todos, { id: action.id, text: action.text, done: false }];
    case "toggled":
      return todos.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
    case "deleted":
      return todos.filter((t) => t.id !== action.id);
    default:
      throw new Error("Unknown action: " + action.type);
  }
}

let nextId = 1;

function App() {
  const [todos, dispatch] = React.useReducer(todosReducer, []);
  const [text, setText] = React.useState("");

  function handleAdd() {
    if (!text.trim()) return;
    dispatch({ type: "added", id: nextId++, text: text.trim() });
    setText("");
  }

  const doneCount = todos.filter((t) => t.done).length;

  return (
    <div>
      <input aria-label="New todo" value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={handleAdd}>Add</button>
      <p>{todos.length} todos, {doneCount} done</p>
      <ul>
        {todos.map((t) => (
          <li key={t.id}>
            <span style={{ textDecoration: t.done ? "line-through" : "none" }}>{t.text}</span>{" "}
            <button aria-label={"Toggle " + t.text} onClick={() => dispatch({ type: "toggled", id: t.id })}>Toggle {t.text}</button>{" "}
            <button aria-label={"Delete " + t.text} onClick={() => dispatch({ type: "deleted", id: t.id })}>Delete {t.text}</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Compare this with the useState version in Updating Arrays in State. The handlers shrink to “what happened” and the rules (how the array changes) live in the reducer, where they are easy to read and to test.

Reducer rules

  1. Pure. Same state and action always give the same result. No fetching, no random numbers, no Date.now() (generate ids in the handler, as nextId++ above).
  2. Never mutate the state. Return a new object or array (Avoiding State Mutation).
  3. One action = one thing that happened, named for the event ("added", "toggled"), not for the setter ("set_todos").
  4. Throw on unknown actions, so typos fail loudly.

Reducer outside the component

Because a reducer is a plain function, you can test it without React:

script.js
JS
const result = todosReducer([], { type: "added", id: 1, text: "Learn" });
// [{ id: 1, text: "Learn", done: false }]

useState or useReducer?

useStateuseReducer
Code sizeLess for simple stateMore setup
Many related fieldsSeveral settersOne place for all rules
Update depends on other fieldsScatteredNatural
Many event handlers change the same stateLogic repeatedLogic shared
Testing the logicTest the componentTest the reducer
Debuggingconsole.log handlersLog every action in one place

Start with useState. Switch to useReducer when you notice handlers doing complicated work on the same piece of state, or when the update logic needs tests.

Reducer plus Context

Put state and dispatch in context (Context API), and any component can dispatch actions. This gives you a small store with no extra library:

App.jsx
JSX
const CounterContext = React.createContext(null);

function counterReducer(state, action) {
  switch (action.type) {
    case "incremented": return { total: state.total + 1 };
    case "reset": return { total: 0 };
    default: throw new Error("Unknown action: " + action.type);
  }
}

function CounterProvider({ children }) {
  const [state, dispatch] = React.useReducer(counterReducer, { total: 0 });
  const value = React.useMemo(() => ({ state, dispatch }), [state]);
  return <CounterContext value={value}>{children}</CounterContext>;
}

function Controls() {
  const { dispatch } = React.useContext(CounterContext);
  return (
    <div>
      <button onClick={() => dispatch({ type: "incremented" })}>Add</button>{" "}
      <button onClick={() => dispatch({ type: "reset" })}>Reset</button>
    </div>
  );
}

function Display() {
  const { state } = React.useContext(CounterContext);
  return <p>Total: {state.total}</p>;
}

function App() {
  return (
    <CounterProvider>
      <Controls />
      <Display />
    </CounterProvider>
  );
}

Lazy initial state

useReducer(reducer, arg, init) takes an optional third argument: a function that computes the initial state from arg, the same idea as a lazy useState initializer.

Common mistakes

  • Mutating state inside the reducer (state.items.push(x)).
  • Side effects in the reducer (network calls, logging to a server).
  • A giant reducer that handles unrelated things. Split it, or use several reducers.
  • Dispatching with no type, or with a type that the reducer does not handle.

Key points

  • A reducer is a pure (state, action) => newState function; dispatch sends it actions.
  • Use it when many handlers change related state; start with useState otherwise.
  • Combine with Context to share state and actions across the app.