Medium Reducers in React
useReducer is not just for complex state-updating logic, it can be used for commonplace state updates; state updates that are not complex, and not super simple either; medium, mundane reducers.
This is a follow-up to my "Actionless and Stateless reducers" post.
In that post, I wrote how I think useReducer
is underutilized and how it’s great for encapsulating state updating logic no matter how simple the state.
However, reducers can also be useful for slightly more complex states in UI, without requiring nested conditions or complicated actions.
In this post, let’s explore the wide middle ground of reducers, where interactions require just a bit of logic. Medium reducers are for reasonably-sized states that aren’t as simple as actionless reducers, but also don’t require complex multi-value state updates.
useState
and useReducer
are interchangeable, with useState
being the simpler and more common of the two. So why not useState
?
Some people like reducers, others don’t. That’s okay. It’s a matter of preference. You can always convert between
useState
anduseReducer
back and forth: they are equivalent!
When state updating logic is spread out over multiple event handlers, it can be hard to reason about what a state can or cannot be. The logic should be sound on each update to avoid impossible states.
Next to that, I think useState
is also less convenient for arrays and objects.
When in state these have to copied before being updated, because state is immutable 1.
That can get a little tedious. Again, this becomes painful when it’s repeated multiple times.
You’re smart and instead of repeating state updating logic in event handlers, you make a function (or multiple) to organize state updating logic — a wrapped state-setter function — and pass that instead of the setter. Good move! That can guard state too, if you make sure to not use the state-setter anywhere else that is.
I tend to avoid callbacks for state updating logic, because I think, callbacks come with drawbacks in React.
- Each callback has to be passed as a prop down the component tree. With multiple callbacks, this becomes a bit of a headache.
- A callback ties logic to the scope of the component it is defined in (due to closure). It’s difficult to remember which props or state are used by functions defined far away from where they are used.
- Functions defined in components are unstable (they have new reference identity every render). To use callbacks in hooks with dependency arrays or pass them to memoized components, they have to be memoized with
useCallback
. Don’t lie to React or the linter, include functions in deps 2 3.
These are not necessarily problems, they can be overcome. I find that callbacks in components just make everything harder, this become especially apparant in large code bases.
Given enough time, you will reach a point where you’ll wonder “Will adding this callback to this dependency array blow up a useEffect
?” or
“Will passing this callback down to a component break a memo
?”.
If you have multiple callback wrapping each other, you’ll wonder what state will be updated when you call it.
Maybe you’ll notice stale values because a callback was omitted from deps somewhere. You’ll have to trace back where the bug came from and that’s no fun.
With useReducer
, all that logic is snug inside the reducer, no need for callbacks, call dispatch
and the update will be handled.
Because state and state logic are all wrapped up in one neat package, it’s easier to see all possible updates and consequently, all possible states at a glance.
And because the reducer encapsulates updates, it embeds constraints so they can’t be violated, making impossible states actually impossible.
Another benefit is that you only need to pass dispatch
, which is guaranteed to be stable 4.
That’s why the React docs also recommended useReducer
and passing dispatch
over many callbacks 5 6.
So summarizing, I think there are two situations where medium reducers can be useful.
- State with constraints: A reducer embeds those constraints.
- State updates that are tedious: A reducer defines the updates once so it avoids repeated tedium.
With that in mind, let’s look at three practical examples of medium reducers. Each example starts with a live example, go ahead and click around!
This is a beautifully simple reducer by Patrick Smith for a very common type of state, the open state for multiple UI items like menus or dialogs of which only one can be open at a time. Clicking an UI element should open it, now for an extra constraint: clicking an already open item should close it.
type Menu = null | "file" | "edit" | "view"; // null means closedfunction menuReducer(state: Menu, action: Menu) { if (action === state) { return null; // Close if it’s already open } return action; // Use passed menu name}const [openMenu, dispatchMenu] = useReducer(menuReducer, null);
Or more tersely, using a ternary operator and placing the reducer inline.
const [openMenu, dispatchMenu] = useReducer( (state, action) => state === action ? null : action, // the reducer null // the initial state);
There are no events in this reducer, the action
is the new state.
By that I mean that the actions are not modelled as events with types for which some logic is performed.
If it’s the same as the currently open one, return null
to close it, otherwise set it to state.
It’s just like a useState()
setter but the check is conveniently baked in. This prevents impossible states, like having multiple menus open at the same time.
On boolean states
If you’re using a boolean state for each menu, simplify and avoid impossible states by condensing all those states into a single state; a string of the open menu. Only one menu can be open at any time, so using multiple boolean states is redundant. More importantly it’s potentially buggy, you could open one menu but forget to close the other and now two menus are open at the same time 7.
See Choosing the State Structure: Avoid contradictions in state in the React docs.
For completeness, here’s what it would look like with useState
:
const [openMenu, setOpenMenu] = useState(null);// reading the state variableconst dispatchMenu = (menu) => { setOpenMenu(openMenu === menu ? null : menu);}// or using a state updater functionconst dispatchMenu = (menu) => { setOpenMenu((prevOpenMenu) => (prevOpenMenu === menu) ? null : menu);}
Put the caster sugar in a small pan over a medium heat and pour in 50ml water. Stir, and bring to the boil. Turn off the heat and allow the mixture to cool.
Next up, a step state. Say we have some steps of a recipe we want to display sequentially. The state is simply the index of the currently visible step. The step can’t go beyond the first or last. That’s the constraint, it really needs to checked when setting state, disabling a button is not enough.
The possible interactions are 1) going to the next or 2) previous page, or 3) jump to a step directly with a number input.
next
and previous
are events that make new state based on prior state, while passing a number directly sets a new state as long as it’s within bounds.
This time, let’s look at how this would be done with useState
first.
const [stepIndex, setStepIndex] = useState(0);const goToPreviousStep = () => { const prevStepIndex = Math.max(0, stepIndex - 1); setStepIndex(prevStepIndex); // or using state updater `setStepIndex(s => Math.max(0, s - 1))`};const goToNextStep = () => { const nextStepIndex = Math.min(steps.length, stepIndex + 1); setStepIndex(nextStepIndex);};const goToStep = (newStepIndex) => { if (stepIndex >= 0 && stepIndex <= steps.length) { setStepIndex(newStepIndex); }};
A callback wrapping the state-setter with ‘guard logic’ for each interaction.
Note that goToStep
depends on data from props; steps.length
. If goToStep
was wrapped in useCallback
, steps.length
should be included in the deps.
For a reducer to access props (or even another state), it can be placed in the React component 8.
Even when the reducer reads data from props or state, dispatch
will still always be stable 9.
function Steps({ steps }) { function stepReducer(stepIndex, action) { if (action === "previous") { const prevStepIndex = Math.max(0, stepIndex - 1); return prevStepIndex; } else if (action === "next") { const nextStepIndex = Math.min(steps.length, stepIndex + 1); return nextStepIndex; } else if ( // check if new step is within bounds typeof action === "number" && action >= 0 && action <= steps.length - 1 ) { return action; } return stepIndex } const [stepIndex, dispatchStep] = useReducer(stepReducer, 0); return <div>{/* More JSX here */}</div>;}
It is an ‘event-driven reducer’, previous
& next
are the events. It includes an ‘escape hatch’ by passing a number to set state directly with ‘guards’.
Tip for the number <input/>
Make the ‘jump to step’ number <input/>
uncontrolled and set the value in the onBlur
handler.
Reset the component by passing the current stepIndex
to the key
attribute.
onChange
in combination with a key
resets focus state on each change, which is not nice.
As a controlled component, only values within the allowed range can be entered and making the field empty when entering input is now allowed. That makes it a tad confusing.
Whereas the uncontrolled version will allow out-of-range numbers to be entered and displayed, but it won’t do anything with it.
The key
with onBlur
approach provides better UX than a controlled component.
And a small CSS tip: Display a warning color when an out-of-bounds value is entered using the :out-of-range
CSS pseudoselecter.
<input type="number" key={stepIndex} min={0} max={steps.length} defaultValue={stepIndex} onBlur={(e) => { const newStepIndex = Number(e.target.value); dispatchStep(newStepIndex); }}/>
If you think that placing the reducer inline clutters up the component, which I do, you can turn it into a higher-order reducer.
Wrap it in a function that takes the necessary data from props or state (here steps.length
) as a parameter and move it out of the component.
I prefer arrow syntax for .
const stepReducer = (length) => (stepIndex, action) { if (action === "previous") { const prevStepIndex = Math.max(0, stepIndex - 1); return prevStepIndex; } else if (action === "next") { const nextStepIndex = Math.min(length - 1, stepIndex + 1); return nextStepIndex; } else if ( // check if new step is within bounds typeof action === "number" && action >= 0 && action <= length - 1 ) { return action; } return stepIndex;}export function Steps({ steps }) { const [stepIndex, dispatchStep] = useReducer(stepReducer(steps.length), 0); // Much cleaner! const currentStep = steps[stepIndex]; return <div>{/* More JSX here */}</div>;}
Neat! Inline reducers are powerful and can do some things that useState
nor any state management library cannot do 10 but that’s something for another post.
How often are you handling an array of strings in state? Me, near daily. I usually prefer a mid-sized reducer over useState
for it.
Let’s imagine we’ve got an array of selected names in state, a name can be added or removed by clicking a button. Only one name can be added or removed at a time. There’s another button to clear all selected names.
To spice things up, let’s add a constraint; no duplicates are allowed.
Let’s write a mid-sized reducer for it alright.
It’ll be a traditional ‘event-driven reducer’ with the action
argument being an object with a type
property and some data, the name
in this case.
function namesReducer(state, action) { const { type } = action; switch (type) { case "add": { if (state.includes(action.name)) { return state; // do nothing if the name is already selected } return [...state, action.name]; } case "remove": { return state.filter((d) => d !== action.name); } case "clear": { return []; } default: return state; }}
It’s a modest three-case reducer. Even with just a few events, it still pays to write a medium reducer to encapsulate and restrict state.
TypeScript Tip
TypeScript makes reducers even sweeter to work with. TypeScript can give give hints and warnings about the actions and possible states.
See Type-checking React useReducer
in TypeScript by BenMVP for more.
If you think an object as the action
parameter is too verbose, you can pass an array with [type, name]
too.
This is shorter but more implicit. You know what the array stands for but it can be confusing to other people working in the codebase.
Tip for people who are lazy and want to confuse their coworkers (or work alone and like terse code):
useReducer
doesn’t actually force you to pass an object.
[A] plain array works toodispatch([’add’, 42])
. In [the] reducer you can switch onaction[0]
and read out.const [, id] = action
. I didn’t tell you this was a good idea
function namesReducer(state, action) { const [type] = action; switch (type) { case "add": { const [, name] = action; if (state.includes(name)) { return state; // do nothing if the name is already selected } return [...state, name]; } case "remove": { return state.filter((d) => d !== action[1]); } case "clear": { return []; } default: return state; }}
And finally, how names
state would be done with useState
:
// adding a name setSelectedNames((s) => s.includes("Jules") ? [...s, "Jules"] : s); // removingsetSelectedNames((s) => s.filter("John")); // clearingsetSelectedNames([]);
To summarize, useState
is more direct, you’re in full control of what the state should be. Sometimes that’s convenient and sometimes it’s not.
You could make callbacks to extract state updating logic but that comes with the aforementioned downsides.
Writing a reducer is more work upfront but it pays off in use, a worthwhile investment in my opinion.
You can be more confident that it doesn’t slip into an impossible state.
An additional benefit is that reducers are more easy to debug and test.
You can toss in a debugger
statement and step through the logic of the reducer and you won’t have to render a compononent tree and fire user events to test it, and yeah console.log
is fine too.
So I’d say try using useReducer
for more commonplace types of state.
The code examples in this post are available in a CodeSandbox.
-
React Docs: Extracting State Logic into a Reducer definitely read this one.
-
A Complete Guide to useEffect by Dan Abramov. Highly recommended!
-
Hooks, State, Closures, and
useReducer
by Adam Rackis. Nice practical post on howuseReducer
can help you step out of the data flow and avoid stale closure bugs. -
Redux Style Guide if you’re writing event-driven reducers, the Redux style guide contains good advice about reducers in general.
-
Hooks, Dependencies and Stale Closures by TkDodo. Nice article about the finer points of callbacks, closure and dependency arrays in React.
-
↩
Arrays are mutable in JavaScript, but you should treat them as immutable when you store them in state. Just like with objects, when you want to update an array stored in state, you need to create a new one (or make a copy of an existing one), and then set state to use the new array.
React Docs: Updating Arrays in State -
↩
It’s difficult to remember which props or state are used by functions outside of the effect. This is why usually you’ll want to declare functions needed by an effect inside of it. Then it’s easy to see what values from the component scope that effect depends on: [..] If you specify a list of dependencies as the last argument to
React docs: Hooks FAQ — Is it safe to omit functions from the list of dependencies? (1)useEffect
,useLayoutEffect
,useMemo
,useCallback
, oruseImperativeHandle
, it must include all values that are used inside the callback and participate in the React data flow. That includes props, state, and anything derived from them. It is only safe to omit a function from the dependency list if nothing in it (or the functions called by it) references props, state, or values derived from them. -
↩
Lying to React about dependencies has bad consequences. Intuitively, this makes sense, but I’ve seen pretty much everyone who tries useEffect with a mental model from classes try to cheat the rules. (And I did that too at first!) “But I only want to run it on mount!”, you’ll say. For now, remember: if you specify deps, all values from inside your component that are used by the effect must be there. Including props, state, functions — anything in your component. Sometimes when you do that, it causes a problem. For example, maybe you see an infinite refetching loop, or a socket is recreated too often. The solution to that problem is not to remove a dependency.
Overreacted: A Complete Guide to useEffect - Don’t Lie to React About Dependencies -
↩
The identity of the dispatch function from useReducer is always stable — even if the reducer function is declared inside the component and reads its props.
React Docs: Hooks FAQ — Is it safe to omit functions from the list of dependencies? (2) -
↩
In large component trees, an alternative we recommend is to pass down a dispatch function from
React Docs Hooks FAQ: How to Avoid Passing Callbacks DownuseReducer
via context: This is both more convenient from the maintenance perspective (no need to keep forwarding callbacks), and avoids the callback problem altogether. Passingdispatch
down like this is the recommended pattern for deep updates. -
↩
Dispatch is better than helper methods. Helper methods are object junk that we need to recreate and compare for no purpose other than superficially nicer looking syntax. There’s no reason to literally pass more things down when you could’ve passed dispatch alone, and then use it directly (or pass it to more things). Passing raw dispatch down also makes code splitting such "async methods" easier. You import them from the leaf component that uses them instead of the root component. So you only pay for what you use where you use it.
Tweets by Dan Abramov -
↩
[On using multiple boolean states]. While this code works, it leaves the door open for “impossible” states. For example, if you forget to call
React Docs: Choosing the State Structure - Avoid contradictions in statesetIsSent
andsetIsSending
together, you may end up in a situation where bothisSending
andisSent
are true at the same time. The more complex your component is, the harder it will be to understand what happened. SinceisSending
andisSent
should never be true at the same time, it is better to replace them with one status state variable that may take one of three valid states: ‘typing’ (initial), ‘sending’, and ‘sent’: -
↩
We’ve seen how to remove dependencies when an effect needs to set state based on previous state, or on another state variable. But what if we need props to calculate the next state? [...]. Surely, in [that] case we can’t avoid specifying
Overreacted: A Complete Guide to useEffect - Why useReducer Is the Cheat Mode of Hooksprops.step
as a dependency? In fact, we can! We can put the reducer itself inside our component to read props: This pattern disables a few optimizations so try not to use it everywhere, but you can totally access props from a reducer if you need to. -
↩
Even in [when a reducer is placed inline],
Overreacted: A Complete Guide to useEffect - Why useReducer Is the Cheat Mode of Hooksdispatch
identity is still guaranteed to be stable between re-renders. So you may omit it from the effect deps if you want. It’s not going to cause [an] effect to re-run. You may be wondering: how can this possibly work? How can the reducer “know” props when called from inside an effect that belongs to another render? The answer is that when you dispatch, React just remembers the action — but it will call your reducer during the next render. At that point the fresh props will be in scope, and you won’t be inside an effect. This is why I like to think ofuseReducer
as the “cheat mode” of Hooks. It lets me decouple the update logic from describing what happened. This, in turn, helps me remove unnecessary dependencies from my effects and avoid re-running them more often than necessary. -
↩
One thing that’s notable is that
Sebastian Markbåge tweetuseReducer
is strictly more powerful (which can lead to fewer optimizations). It can do something that no third-party [library] noruseState
can. It can respond to changes to a parent in the same batch, has already been queued or happens at higher pri[ority].