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
count
to thehistory
stack. - Add a new
case
to handle theundo
action.
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.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
- Use
React.useReducer
when you need to manage multiple tightly-coupled state values. - Reducers are capable of modeling complex systems.
- Consider making your
useReducer
hook 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.