React.useReducer Reducer Patterns, Part 3
So far in parts 1 and 2 of this series, we’ve learned that:
- The primary job of a reducer is to produce a new state.
- Dispatched actions can take any shape you want.
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:
- Return the current state as-is if we add zero, since this action doesn’t change the state.
- If we’re adding any other value, also add the current
countto thehistorystack. - Add a new
caseto handle theundoaction.
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:
- The input will only accept
[A-Za-z]characters - Submit button to POST the input value to an API
- Reset button to reset the input value to the last successful submission.
Simple, right? That’s often how forms start, but complexity arises when you consider:
- If input is invalid, display error message and don’t allow submission.
- Don’t allow submission if input value hasn’t changed from the previous submission.
- During submission, disable all inputs.
- Display submission status.
- When a submission error occurs, the submit button should remain active to allow for retry.
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:
- Specify the next state by changing
state.value. - Changing
state.contextas 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
- Use
React.useReducerwhen you need to manage multiple tightly-coupled state values. - Reducers are capable of modeling complex systems.
- Consider making your
useReducerhook more user-friendly by providing action creators.
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.
George Song