George Song

React.useReducer Reducer Patterns, Part 3

So far in parts 1 and 2 of this series, we’ve learned that:

In part 3, we’ll look at the most common pattern for reducers and actions you’ll find in documentation and online examples, and we’ll model a state chart using a reducer.

Table of Contents

Typical useReducer Pattern

Most React.useReducer examples you encounter dispatch actions resembling { type, payload }. Their reducer functions typically consist of a switch statement that matches on type and uses payload to calculate the next state.

Remember, the reducer signature is (currentState, action) => nextState. A switch-statement-based reducer looks like this:

const reducer = (count, { type, payload }) => {
switch (type) {
case "add":
return count + payload;
case "reset":
return initialValue;
default:
throw new Error();
}
};

To trigger this reducer, there are two possible actions we can dispatch.

dispatch({ type: "add", payload: 5 });
dispatch({ type: "reset" });

We can encapsulate these two possibilities with bound action creators:

const add = (payload) => dispatch({ type: "add", payload });
const reset = () => dispatch({ type: "reset" });

Action creators are optional; you can dispatch actions directly where needed instead. However, they offer a cleaner interface for the hook’s user by hiding the reducer’s implementation details.

The complete useReducer definition looks like this:

const initialValue = 0;
const reducer = (count, { type, payload }) => {
switch (type) {
case "add":
return count + payload;
case "reset":
return initialValue;
default:
throw new Error();
}
};
const [count, dispatch] = React.useReducer(reducer, initialValue);
const add = (payload) => dispatch({ type: "add", payload });
const reset = () => dispatch({ type: "reset" });

Our hook interface is now count, plus the add() and reset() functions.

Try It Out

Tightly Coupled State Values

So far, we’ve explored several reducer and action patterns, but we haven’t discussed why we use useReducer. In fact, you can express the previous example more clearly using a useState hook:

const initialValue = 0;
const [count, setCount] = React.useState(initialValue);
const add = (value) => setCount(count + value);
const reset = () => setCount(initialValue);

Try It Out

useState is optimized for managing a single state value. This implies that useReducer excels when we need to manage multiple related state values.

What if we want to add an undo function to retrieve previous counts? One way to do this is to track the count history. Instead of only tracking the latest count, our state object now looks like this:

const initialState = { count: 0, history: [] };

We update the reducer with the following changes:

const reducer = (state, { type, payload }) => {
const { count, history } = state;
switch (type) {
case "add":
if (payload === 0) return state;
return { count: count + payload, history: [...history, count] };
case "reset":
return initialState;
case "undo":
if (history.length === 0) return state;
const lastCount = [...history].pop();
return { count: lastCount, history: history.slice(0, -1) };
default:
throw new Error();
}
};

Lastly, we add another bound action creator to handle undo():

const undo = () => dispatch({ type: "undo" });

Putting it all together:

const initialState = { count: 0, history: [] };
const reducer = (state, { type, payload }) => {
const { count, history } = state;
switch (type) {
case "add":
if (payload === 0) return state;
return { count: count + payload, history: [...history, count] };
case "reset":
return initialState;
case "undo":
if (history.length === 0) return state;
const lastCount = [...history].pop();
return { count: lastCount, history: history.slice(0, -1) };
default:
throw new Error();
}
};
const [{ count, history }, dispatch] = React.useReducer(reducer, initialState);
const add = (payload) => dispatch({ type: "add", payload });
const reset = () => dispatch({ type: "reset" });
const undo = () => dispatch({ type: "undo" });

We see that count and history are tightly coupled. When we add a value, we update count and add the previous count to the history stack. When we undo, we replace count by popping the last item from the history stack.

Try It Out

Nested Switch Statements

Did you know you can nest a switch statement inside the case clause of another switch statement? Using this technique, you can implement sophisticated logic with the useReducer hook, especially when combined with useEffect.

Let’s create a seemingly simple form with a single input and a couple of buttons:

Simple, right? That’s often how forms start, but complexity arises when you consider:

How can you satisfy all these requirements? Should you try to capture all the logic imperatively? Should you use one of the many React form libraries? I believe state charts offer a great way to declaratively satisfy all these requirements.

Our state chart has four states—editing, submitting, resolved, rejected—with clear transitions defined among the states, e.g. we transition from editing to submitting state by performing the submit action.

Initial Hook State

First, let’s define what our useReducer hook state looks like. Note that the hook’s state is distinct from the state chart’s state. Our hook state looks like this:

const initialState = {
value: "editing",
context: {
previousValue: "",
value: "",
isValid: true,
submitAllowed: false,
isSuccessful: undefined,
},
};

The value property holds our state chart’s current state. We also have a separate context property that tracks the extended state.

Translate a State Chart to a Reducer Function

const reducer = (state, { type, payload } = {}) => {
const { value, context } = state;
// Top-level switch based on the state chart's current state (state.value)
switch (value) {
case "editing":
// Nested switch based on the action type (transition)
switch (type) {
case "change":
const isValid = /^[A-Za-z]*$/.test(payload);
const submitAllowed = isValid && context.previousValue !== payload;
return {
value,
context: { ...context, value: payload, isValid, submitAllowed },
};
18 collapsed lines
case "reset":
return {
value,
context: {
...context,
value: context.previousValue,
isValid: true,
submitAllowed: false,
},
};
case "submit":
if (context.submitAllowed) return { value: "submitting", context };
return state;
default:
return state;
}
case "submitting":
switch (type) {
case "resolve":
return { value: "resolved", context };
4 collapsed lines
case "reject":
return { value: "rejected", context };
default:
return state;
}
case "resolved":
9 collapsed lines
return {
value: "editing",
context: {
...context,
previousValue: context.value,
isSuccessful: true,
submitAllowed: false,
},
};
case "rejected":
7 collapsed lines
return {
value: "editing",
context: {
...context,
isSuccessful: false,
},
};
default:
return state;
}
};

Notice the top-level switch statement evaluates the state.value (the state chart’s current state). For the editing and submitting states, we use a nested switch based on action.type (representing the state chart transition). Why is this done? This guarantees that, for example, the submit transition is only valid when the state chart is in the editing state. Practically, this means that if the form is already in the submitting state, it cannot be submitted again, regardless of user actions (solving the multiple button click issue, even if the submit button isn’t explicitly disabled).

If you compare the code to the diagram, you’ll see that each case clause corresponds to a transition—seven transitions mean seven case clauses that return the next state.

Notice that the default clauses always return the original state unmodified. This aligns with how state machines typically work: attempting an invalid transition leaves the state unchanged.

Looking at each case clause, you’ll notice we’re performing a couple of tasks:

  1. Specify the next state by changing state.value.
  2. Changing state.context as appropriate for a given transition.

Putting It All Together

const [state, dispatch] = React.useReducer(reducer, initialState);
const transitions = {
change: (payload) => dispatch({ type: "change", payload }),
reset: () => dispatch({ type: "reset" }),
submit: () => dispatch({ type: "submit" }),
};

To trigger transitions, we use the dispatch() function returned by the useReducer hook. Transitions can be triggered manually (e.g., by a user clicking a button) via event handlers, or automatically (e.g., when state changes) via useEffect.

Discussing complex state chart topics in depth is beyond the scope of this article. What we’re demonstrating here is that you can model fairly complex systems using reducers, if somewhat awkwardly.

Try It Out

If you’re curious about state charts, I highly recommend checking out XState.

Takeaways

As demonstrated in this series, useReducer is a hook pattern capable of solving a wide variety of problems, from simple to moderately complex. Now that the potentially intimidating aspect of the pattern (reducers) is hopefully less daunting, I encourage you to use useReducer more frequently and creatively.