Advanced30 min read

useReducer

Learn the reducer pattern for managing complex state in React. Understand when to use useReducer over useState, how to dispatch actions, and how to combine reducers with context.

When useState Is Not Enough

useState is perfect for simple, independent pieces of state: a counter, a toggle, a form field. But as your component grows, you may find yourself juggling many related state variables that change in coordinated ways.

Consider a todo list component:

jsx
function TodoApp() {
  const [todos, setTodos] = React.useState([]);
  const [filter, setFilter] = React.useState('all');
  const [editingId, setEditingId] = React.useState(null);

  function addTodo(text) {
    setTodos([...todos, { id: Date.now(), text, done: false }]);
  }

  function toggleTodo(id) {
    setTodos(todos.map(t => t.id === id ? { ...t, done: !t.done } : t));
  }

  function deleteTodo(id) {
    setTodos(todos.filter(t => t.id !== id));
    if (editingId === id) setEditingId(null); // coordinated update!
  }
  // ... more handlers
}

Notice how deleteTodo needs to update two pieces of state, and how each handler contains logic for computing the next state. As the component grows, these update functions become complex and error-prone.

This is where useReducer shines. It lets you:

  • Centralize all state update logic in one place (the reducer function)
  • Describe updates as actions ("what happened") rather than imperative state mutations
  • Keep state transitions predictable and easy to test
  • Separate the "what happened" (dispatching an action) from the "how to update" (the reducer)

The Reducer Pattern

A reducer is a pure function that takes the current state and an action, and returns the new state:

(state, action) => newState

This pattern comes from the Array.reduce() method and is the foundation of libraries like Redux. The key idea is that all state transitions are described by actions — plain objects that say what happened.

Action objects

Actions are plain JavaScript objects with a type property (a string that describes the event) and an optional payload (additional data):

javascript
{ type: 'ADD_TODO', payload: { text: 'Learn React' } }
{ type: 'TOGGLE_TODO', payload: { id: 1 } }
{ type: 'DELETE_TODO', payload: { id: 1 } }
{ type: 'CLEAR_ALL' }  // no payload needed

The reducer function

The reducer examines the action type and computes the new state accordingly:

javascript
function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [...state.todos, {
          id: Date.now(),
          text: action.payload.text,
          done: false,
        }],
      };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload.id
            ? { ...todo, done: !todo.done }
            : todo
        ),
      };
    case 'DELETE_TODO':
      return {
        ...state,
        todos: state.todos.filter(todo => todo.id !== action.payload.id),
      };
    default:
      return state;
  }
}

Important rules for reducers:

  1. Pure function — same inputs always produce the same outputs. No side effects (no API calls, no localStorage, no Math.random()).
  2. Never mutate state — always return a new object. Use the spread operator (...state) to copy existing properties.
  3. Handle unknown actions — the default case should return the current state unchanged.

useReducer Syntax and Dispatch

React's useReducer hook takes a reducer function and an initial state, and returns the current state plus a dispatch function:

jsx
const [state, dispatch] = React.useReducer(reducer, initialState);
  • state — the current state value
  • dispatch — a function you call to send actions to the reducer

Dispatching actions

To trigger a state update, call dispatch with an action object:

jsx
function TodoApp() {
  const [state, dispatch] = React.useReducer(todoReducer, { todos: [] });

  function handleAdd(text) {
    dispatch({ type: 'ADD_TODO', payload: { text } });
  }

  function handleToggle(id) {
    dispatch({ type: 'TOGGLE_TODO', payload: { id } });
  }

  return (
    <div>
      <button onClick={() => handleAdd('New task')}>Add</button>
      <ul>
        {state.todos.map(todo => (
          <li
            key={todo.id}
            onClick={() => handleToggle(todo.id)}
            style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
          >
            {todo.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

When you call dispatch, React:

  1. Calls your reducer with the current state and the action
  2. Takes the return value as the new state
  3. Re-renders the component with the new state

When to choose useReducer over useState

Use useState when...Use useReducer when...
State is a single value (number, string, boolean)State is an object or array with multiple sub-values
Updates are simple (set to a new value)Updates depend on the previous state in complex ways
Few state transitionsMany different state transitions
Logic is straightforwardYou want to centralize and test update logic

Combining useReducer with Context

One of the most powerful patterns in React is combining useReducer with Context. This gives you a simple, built-in state management solution that can replace Redux for many use cases.

The idea: store your reducer's state and dispatch in context so that any component in the tree can read state or dispatch actions without prop drilling.

jsx
const TodoContext = React.createContext();

function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [...state.todos, {
          id: Date.now(),
          text: action.payload.text,
          done: false,
        }],
      };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map(t =>
          t.id === action.payload.id ? { ...t, done: !t.done } : t
        ),
      };
    default:
      return state;
  }
}

function TodoProvider({ children }) {
  const [state, dispatch] = React.useReducer(todoReducer, { todos: [] });
  return (
    <TodoContext.Provider value={{ state, dispatch }}>
      {children}
    </TodoContext.Provider>
  );
}

// Any deeply nested component can dispatch actions:
function AddButton() {
  const { dispatch } = React.useContext(TodoContext);
  return (
    <button onClick={() => dispatch({
      type: 'ADD_TODO',
      payload: { text: 'New task' }
    })}>
      Add Todo
    </button>
  );
}

// Any deeply nested component can read state:
function TodoList() {
  const { state } = React.useContext(TodoContext);
  return (
    <ul>
      {state.todos.map(todo => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}

This pattern is lightweight and does not require any third-party library. It works well for medium-sized applications. For very large applications with frequent updates, dedicated state management libraries may offer better performance through selective subscriptions.

useReducer in Action

html
<div id="root"></div>
<script src="https://unpkg.com/react@19/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@19/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
  function counterReducer(state, action) {
    switch (action.type) {
      case "INCREMENT":
        return { ...state, count: state.count + 1 };
      case "DECREMENT":
        return { ...state, count: state.count - 1 };
      case "RESET":
        return { ...state, count: 0 };
      case "SET":
        return { ...state, count: action.payload };
      default:
        return state;
    }
  }

  function App() {
    const [state, dispatch] = React.useReducer(counterReducer, { count: 0 });

    return (
      <div>
        <h2>useReducer Counter</h2>
        <p>Count: {state.count}</p>
        <button onClick={() => dispatch({ type: "INCREMENT" })}>+1</button>
        <button onClick={() => dispatch({ type: "DECREMENT" })}>-1</button>
        <button onClick={() => dispatch({ type: "RESET" })}>Reset</button>
        <button onClick={() => dispatch({ type: "SET", payload: 100 })}>Set to 100</button>
      </div>
    );
  }

  ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>

What are the two arguments that a reducer function receives?

Ready to practice?

Create your free account to access the interactive code editor, run challenges, and track your progress.