Medium Reducers in React
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.
A reducer can even be used without reading the action
or state
parameters.
But there’s a wide middle ground between very simple and complex multi-value state updates, think of interactions that require just a bit of logic. Let’s consider reducers for this more mundane middle. These are reasonably-sized reducers that don’t need many nested conditions but also aren’t simple enough to be actionless — rather; medium reducers.
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. More relevant to medium reducers, when a state has some , mistake are made more easily when logic is repeated in multiple event handlers. 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 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 a function (or multiple) to organize state updating logic — a wrapped state setter — 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 . 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 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 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, just 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.
_11type Menu = null | "file" | "edit" | "view"; // null means closed_11_11function menuReducer(state: Menu, action: Menu) {_11 if (action === state) {_11 return null; // Close if it’s already open_11 }_11_11 return action; // Use passed menu name_11}_11_11const [openMenu, dispatchMenu] = useReducer(menuReducer, null);
Or more tersely, using a ternary operator and placing the reducer inline.
_10const [openMenu, dispatchMenu] = useReducer(_10 (state, action) => state === action ? null : action, // the reducer_10 null // the initial state_10);
There are no in this reducer, the action
is the new state. 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
:
_11const [openMenu, setOpenMenu] = useState(null);_11_11// reading the state variable_11const dispatchMenu = (menu) => {_11 setOpenMenu(openMenu === menu ? null : menu);_11}_11_11// or using a state updater function_11const dispatchMenu = (menu) => {_11 setOpenMenu((prevOpenMenu) => (prevOpenMenu === menu) ? null : menu);_11}
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.
_17const [stepIndex, setStepIndex] = useState(0);_17_17const goToPreviousStep = () => {_17 const prevStepIndex = Math.max(0, stepIndex - 1);_17 setStepIndex(prevStepIndex); // or using state updater `setStepIndex(s => Math.max(0, s - 1))`_17};_17_17const goToNextStep = () => {_17 const nextStepIndex = Math.min(steps.length, stepIndex + 1);_17 setStepIndex(nextStepIndex);_17};_17_17const goToStep = (newStepIndex) => {_17 if (stepIndex >= 0 && stepIndex <= steps.length) {_17 setStepIndex(newStepIndex);_17 }_17};
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.
_25function Steps({ steps }) {_25 function stepReducer(stepIndex, action) {_25 if (action === "previous") {_25 const prevStepIndex = Math.max(0, stepIndex - 1);_25 _25 return prevStepIndex;_25 } else if (action === "next") {_25 const nextStepIndex = Math.min(steps.length, stepIndex + 1);_25_25 return nextStepIndex;_25 } else if ( // check if new step is within bounds_25 typeof action === "number" &&_25 action >= 0 &&_25 action <= steps.length - 1_25 ) {_25 return action;_25 }_25_25 return stepIndex_25 }_25_25 const [stepIndex, dispatchStep] = useReducer(stepReducer, 0);_25_25 return <div>{/* More JSX here */}</div>;_25}
It is an ‘event-driven reducer’ (previous
& next
) with an escape hatch to set state directly but 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.
_11 <input_11 type="number"_11 key={stepIndex}_11 min={0}_11 max={steps.length}_11 defaultValue={stepIndex}_11 onBlur={(e) => {_11 const newStepIndex = Number(e.target.value);_11 dispatchStep(newStepIndex);_11 }}_11/>
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 :
_29const stepReducer = (length) => (stepIndex, action) {_29 if (action === "previous") {_29 const prevStepIndex = Math.max(0, stepIndex - 1);_29_29 return prevStepIndex;_29 } else if (action === "next") {_29 const nextStepIndex = Math.min(length - 1, stepIndex + 1);_29_29 return nextStepIndex;_29 } else if (_29 // check if new step is within bounds_29 typeof action === "number" &&_29 action >= 0 &&_29 action <= length - 1_29 ) {_29 return action;_29 }_29_29 return stepIndex;_29}_29_29export function Steps({ steps }) {_29 const [stepIndex, dispatchStep] = useReducer(stepReducer(steps.length), 0);_29 // Much cleaner!_29_29 const currentStep = steps[stepIndex];_29_29 return <div>{/* More JSX here */}</div>;_29}
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.
_20function namesReducer(state, action) {_20 const { type } = action;_20_20 switch (type) {_20 case "add": {_20 if (state.includes(action.name)) {_20 return state; // do nothing if the name is already selected_20 }_20 return [...state, action.name];_20 }_20 case "remove": {_20 return state.filter((d) => d !== action.name);_20 }_20 case "clear": {_20 return [];_20 }_20 default:_20 return state;_20 }_20}
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
_21function namesReducer(state, action) {_21 const [type] = action;_21_21 switch (type) {_21 case "add": {_21 const [, name] = action;_21 if (state.includes(name)) {_21 return state; // do nothing if the name is already selected_21 }_21 return [...state, name];_21 }_21 case "remove": {_21 return state.filter((d) => d !== action[1]);_21 }_21 case "clear": {_21 return [];_21 }_21 default:_21 return state;_21 }_21}
And finally, how names
state would be done with useState
:
_10// adding a name _10setSelectedNames((s) => s.includes("Jules") ? [...s, "Jules"] : s); _10// removing_10setSelectedNames((s) => s.filter("John"); _10// clearing_10setSelectedNames([]);
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 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
's 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.
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
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],
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].