React useReducer Explained: Manage Complex State with Actions

React.js Full Course: Beginner to Advanced · Hooks and Data · 9 min read · Updated

Actions and a reducer

  1. The component does not change the data itself. It sends a note describing what happened.
  2. The note is an action, a small object such as { type: "added", text: "Buy milk" }.
  3. React gives the current state and the action to the reducer function.
  4. The reducer follows its rules and returns a new state. It never changes the old state.
  5. React draws again with the new state. All the rules live in one place.

useState is perfect for simple values. But when a component has many pieces of state that change together, or many ways to change them, the code can become messy. The useReducer hook gives you a tidy way to organise it. It is a favourite for complex state, and it is also the idea behind Redux, which you will meet later.

The problem: too many setters

Look at a to-do list that can add, toggle, delete and clear finished tasks. With useState, handlers each have their own logic spread around the component:

function addTask(text) { setTasks([...tasks, { id: Date.now(), text, done: false }]); }
function toggleTask(id) { setTasks(tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t))); }
function deleteTask(id) { setTasks(tasks.filter((t) => t.id !== id)); }
function clearDone() { setTasks(tasks.filter((t) => !t.done)); }

This is fine for four. For fifteen kinds of changes, or when the state has many fields, it is hard to see all the rules in one place. useReducer moves all of the rules into one function.

The idea: actions and a reducer

  • An action is a small object that describes what happened, for example { type: 'added', text: 'Buy milk' }. It does not change anything. It is a message.
  • A reducer is a function that takes the current state and an action, and returns the new state. All of the rules are in it.
  • dispatch is the function you call to send an action to the reducer.

You can think of it like a shop: customers (your components) do not walk to the shelves and change the stock. They hand a note (an action) to the shopkeeper (the reducer), who updates the stock by the rules.

A first example: the counter

import { useReducer } from 'react';

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return { count: 0 };
    default:
      throw new Error('Unknown action: ' + action.type);
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 });

  return (
    <div>
      <p>{state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </div>
  );
}

useReducer(reducer, initialState) returns the current state and dispatch. When you call dispatch({ type: 'increment' }), React calls reducer(currentState, thatAction) and uses the returned object as the new state, then draws again. The switch lists every way the state can change, in one readable place.

The rules of a reducer

  • It must be pure: the same state and action always give the same result. No fetching, no random numbers, no changing outside things inside it.
  • It must not change the old state. Return a new object or array. Copy with spread, map and filter, just as in the state lessons.
  • Always return something. If an action is not known, throw an error or return the old state.

A real example: a to-do list

const initial = [];

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));
    case 'deleted':
      return tasks.filter((t) => t.id !== action.id);
    case 'cleared':
      return tasks.filter((t) => !t.done);
    default:
      throw new Error('Unknown action: ' + action.type);
  }
}

function TodoApp() {
  const [tasks, dispatch] = useReducer(tasksReducer, initial);
  const [text, setText] = useState('');

  function handleAdd(event) {
    event.preventDefault();
    if (!text.trim()) return;
    dispatch({ type: 'added', id: crypto.randomUUID(), text: text.trim() });
    setText('');
  }

  return (
    <div>
      <form onSubmit={handleAdd}>
        <input value={text} onChange={(e) => setText(e.target.value)} />
        <button>Add</button>
      </form>
      <ul>
        {tasks.map((t) => (
          <li key={t.id}>
            <label>
              <input type="checkbox" checked={t.done} onChange={() => dispatch({ type: 'toggled', id: t.id })} />
              {t.text}
            </label>
            <button onClick={() => dispatch({ type: 'deleted', id: t.id })}>Delete</button>
          </li>
        ))}
      </ul>
      <button onClick={() => dispatch({ type: 'cleared' })}>Clear finished</button>
    </div>
  );
}

The component only says what happened (added, toggled, deleted). It does not know how the list changes. All the rules live in tasksReducer. You can read that one function and understand every change. You can even move it to its own file and test it without React, because it is a plain function.

Notice that the form text uses a normal useState. You can mix them: use useState for simple, local values and useReducer for the state with many rules. The id is made before dispatching, because a reducer must be pure and cannot make random ids itself.

Action names

Name actions after what happened, in the past tense: 'added', 'deleted', 'form_submitted', rather than commands like 'setTasks'. This keeps the reducer about events and makes logs easy to read. You may send extra data in the action, such as id or text, often named payload.

useState or useReducer?

  • useState is shorter for simple, independent values: a toggle, a text box, a counter.
  • useReducer shines when the next state depends on the old one in several ways, when state has many related fields, or when you want all the rules in one place that is easy to test.
  • Start with useState. Move to useReducer when your handlers start to look like a pile of rules.

A bonus: when many deep components need to change the state, you can pass dispatch down instead of many handler functions. React keeps dispatch the same between draws, so it is also safe in effects. Together with context (lesson 16), useReducer gives you a small state manager without any library.

A form state example

function formReducer(state, action) {
  switch (action.type) {
    case 'field_changed':
      return { ...state, values: { ...state.values, [action.name]: action.value } };
    case 'submit_started':
      return { ...state, status: 'sending', error: '' };
    case 'submit_failed':
      return { ...state, status: 'idle', error: action.message };
    case 'submit_succeeded':
      return { values: { name: '', email: '' }, status: 'done', error: '' };
    default:
      throw new Error('Unknown action: ' + action.type);
  }
}

const start = { values: { name: '', email: '' }, status: 'idle', error: '' };

Here the form has values, a status and an error that change together. With four named actions, each step of the form is easy to read: a field changed, sending started, sending failed, sending worked. The status can be idle, sending or done, so there are no impossible combinations like “loading” and “done” both true.

Common mistakes

  • Changing the state inside the reducer (state.count++ or tasks.push(...)). Return a new value.
  • Making ids or calling fetch inside the reducer. Do those before dispatching.
  • Forgetting a return in a case, so the new state becomes undefined.
  • Writing actions as commands that hide meaning, such as 'set'.
  • Using useReducer for a single toggle, which is more code than needed.

Following one dispatch

Let us follow a single click through the to-do reducer so that nothing feels hidden. The person ticks the box of the task with id 7.

  1. The checkbox calls dispatch({ type: 'toggled', id: 7 }).
  2. React calls tasksReducer(currentTasks, { type: 'toggled', id: 7 }).
  3. The reducer finds the 'toggled' case. It goes through the list with map, and for the task with id 7 it returns a new object with done flipped. All other tasks are returned as they are.
  4. The reducer returns a new array. React sees that the state is a different object, and draws the component again.
  5. The list is drawn from the new array, and the box for task 7 shows as ticked.

The component never needed to know how toggling works. If you later decide that ticking a task should also record the time it was finished, you change one case in the reducer, and nothing else. That is the real benefit: the rules live in one place.

Your task

  1. Build the counter with a step field, and an action 'step_changed'.
  2. Build the to-do list above, and add an action 'edited' that changes the text of a task.
  3. Build a shopping cart reducer with actions 'added', 'quantity_changed' and 'removed', and show the total price.
  4. Write the reducer in a separate file and test it with a few plain function calls in the console.

How to check it worked

  • The list stays correct after any mix of actions.
  • The reducer never changes the old state (you always return a new array or object).
  • You can find every possible change of the state in one function.

Summary

  • useReducer(reducer, initial) gives [state, dispatch]. Call dispatch(action) and React runs the reducer to get the new state.
  • An action describes what happened. A reducer is a pure function (state, action) => newState.
  • Never change old state in a reducer. Return new arrays and objects.
  • Start with useState. Use useReducer when state logic becomes complex.

Frequently asked questions

When should I use useReducer instead of useState?

When the state has many related fields or many kinds of updates, and you want all the rules in one place that is easy to read and test. For simple values, useState is shorter.

What is an action?

A small object that describes what happened, such as { type: "added", text: "Buy milk" }. It is sent to the reducer with dispatch.

Why must a reducer be pure?

React may call it more than once and expects the same result for the same input. Doing outside work inside it, like fetching, makes it unpredictable.

Is useReducer the same as Redux?

The idea is the same: actions and a reducer. Redux adds a global store, tools and extra features for large apps. useReducer works inside one component tree.