Skip to content

Modern React 7: Reducers for multi-step state

This is part 7 of the Modern React development series.

Reducers make state transitions readable when a component has several related updates. Instead of spreading update rules across handlers, a reducer names the actions and centralizes how each action changes state.

Concept

useReducer is a React Hook for managing state with a reducer function. The reducer receives the current state and an action object, then returns the next state. For UI state, the reducer should be pure.

Terms

  • Reducer: A function that returns next state from current state and an action.
  • Action: A value that describes what happened, often an object with a type field.
  • Dispatch: The function React gives you to send an action to the reducer.
  • Pure function: A function that returns a value without changing outside state or performing side effects.

Mental model

Think of a reducer as a state machine table. Each action name selects one row of rules, and the reducer returns the next snapshot of the machine.

How it is used

Reducers fit multi-step forms, wizard state, carts, editors, filters with several fields, and components where one event updates several related values. They also make transition rules easier to test as plain functions.

How to use it

  1. Define the state shape as one object.
  2. Define action types that describe user or system events.
  3. Write a pure reducer that returns a new state for each action.
  4. Call dispatch from event handlers.
  5. Keep network calls, logging, and storage outside the reducer.

Example: Counter reducer

import { useReducer } from "react";
type State = { count: number };
type Action = { type: "increment" } | { type: "reset" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "reset":
return { count: 0 };
}
}
export function ReducerCounter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>Add</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</>
);
}
React output

0

The handler reports intent. The reducer owns the transition rule.

Example: Wizard reducer

import { useReducer, type Reducer } from "react";
type WizardState = {
step: "account" | "profile" | "confirm";
email: string;
displayName: string;
};
type WizardAction =
| { type: "emailChanged"; email: string }
| { type: "profileSaved"; displayName: string }
| { type: "back" };
const wizardReducer: Reducer<WizardState, WizardAction> = (state, action) => {
switch (action.type) {
case "emailChanged":
return { ...state, email: action.email };
case "profileSaved":
return { ...state, displayName: action.displayName, step: "confirm" };
case "back":
return { ...state, step: "account" };
}
};
const initialWizardState: WizardState = {
step: "account",
email: "",
displayName: "",
};
export function WizardDemo() {
const [state, dispatch] = useReducer(wizardReducer, initialWizardState);
return (
<section aria-label="Signup wizard">
<p>Step: {state.step}</p>
<p>Email: {state.email || "Not set"}</p>
<p>Name: {state.displayName || "Not set"}</p>
<button
type="button"
onClick={() =>
dispatch({ type: "emailChanged", email: "reader@example.com" })
}
>
Use saved email
</button>
<button
type="button"
onClick={() =>
dispatch({ type: "profileSaved", displayName: "Reader" })
}
>
Save profile
</button>
<button type="button" onClick={() => dispatch({ type: "back" })}>
Back
</button>
</section>
);
}
React output

Step: account

Email: Not set

Name: Not set

A reducer keeps step movement and data edits in one transition model instead of scattering them across screens.

Details to watch

  • Purity: useReducer reducers should not fetch, write storage, create timers, or mutate existing state.
  • Action names: Name actions after what happened, not only after what field changes.
  • State objects: Return new objects and arrays so React can see that state changed.
  • Scale: A reducer helps when transitions are related. useState is still clearer for one or two independent values.

Series navigation

References