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
typefield. - 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
- Define the state shape as one object.
- Define action types that describe user or system events.
- Write a pure reducer that returns a new state for each action.
- Call
dispatchfrom event handlers. - 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> </> );}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> );}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:
useReducerreducers 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.
useStateis still clearer for one or two independent values.
Series navigation
- Previous: Part 6: Lifting state and controlled inputs
- Next: Part 8: Context without global soup
- Series index: Modern React development