React useReducer

useReducer manages state transitions through a pure reducer function and dispatched action objects, making complex or interrelated state updates easier to follow than a pile of useState calls.

From useState to a Reducer

useState works well for independent pieces of state, but once several values change together in response to the same event — like a form that tracks values, errors, and submission status — you end up writing the same update logic in multiple places. useReducer centralizes that logic into a single function that takes the current state and an action, and returns the next state.

The Shape of a Reducer

A reducer is a pure function: (state, action) => newState. It never mutates the existing state object, never causes side effects, and always returns a full replacement for state given the same inputs. Actions are typically plain objects with a type field describing what happened, plus whatever payload the reducer needs to compute the next state.

  • type — a string describing the event, e.g. 'increment' or 'field_changed'
  • payload data — any extra values the reducer needs (naming is a convention, not a rule)
  • The reducer switches on action.type and returns a new state object for each case
  • An unrecognized action.type should return the existing state unchanged, or throw to catch typos early

A Counter With useReducer

import { useReducer } from 'react';

function counterReducer(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 type: ${action.type}`);
  }
}

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

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

export default Counter;

A Reducer for a Multi-Field Form

import { useReducer } from 'react';

const initialForm = {
  values: { name: '', email: '' },
  errors: {},
  submitted: false,
};

function formReducer(state, action) {
  switch (action.type) {
    case 'field_changed':
      return {
        ...state,
        values: { ...state.values, [action.field]: action.value },
      };
    case 'validation_failed':
      return { ...state, errors: action.errors };
    case 'submit_succeeded':
      return { ...state, errors: {}, submitted: true };
    default:
      return state;
  }
}

function SignupForm() {
  const [state, dispatch] = useReducer(formReducer, initialForm);

  function handleChange(e) {
    dispatch({
      type: 'field_changed',
      field: e.target.name,
      value: e.target.value,
    });
  }

  return (
    <form>
      <input name="name" value={state.values.name} onChange={handleChange} />
      <input name="email" value={state.values.email} onChange={handleChange} />
      {state.submitted && <p>Thanks for signing up!</p>}
    </form>
  );
}

export default SignupForm;
Note: Think of dispatch as narrating what happened, not what should happen next. dispatch({ type: 'increment' }) reads like an event; dispatch({ type: 'setCountTo5' }) reads like a command hiding in an action — the reducer, not the caller, should decide the resulting number.

Lazy Initialization With a Third Argument

import { useReducer, useEffect } from 'react';

function counterReducer(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 type: ${action.type}`);
  }
}

function init(initialCount) {
  // Runs once, on mount only — useful for expensive setup
  // like reading from localStorage.
  const saved = window.localStorage.getItem('count');
  return { count: saved ? Number(saved) : initialCount };
}

function PersistentCounter({ initialCount }) {
  const [state, dispatch] = useReducer(counterReducer, initialCount, init);

  useEffect(() => {
    window.localStorage.setItem('count', state.count);
  }, [state.count]);

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

export default function App() {
  return <PersistentCounter initialCount={0} />;
}
SituationuseStateuseReducer
Independent primitive valuesSimple and directAdds unnecessary ceremony
Several values that update togetherUpdate logic scattered across handlersOne function holds all the transition logic
Next state depends on previous state in complex waysEasy to get stale-closure bugsReducer always receives the latest state as an argument
Testing state logic in isolationHard to test without rendering a componentReducer is a pure function you can test directly
Passing updates down deeplyMust pass multiple setter functionsPass a single dispatch function
Note: Reducers must stay pure. Don't call fetch, read Date.now(), generate random ids, or mutate arrays/objects in place inside a reducer — do that in an effect or event handler and pass the result in as part of the action's payload instead.

Exercise: React useReducer

What two arguments does a reducer function receive?