Actionless and Stateless Reducers in React
useReducer is not just for complex state-updating logic, it can be used for very simple state updates. One of the reducers two arguments can be ignored, making it either actionless or stateless.
I think useReducer
is pretty great and underutilized, and conversely useState
overutilized.
useReducer
usually comes in to play when state updating logic gets unwieldy 1,
so unwieldy and scattered over many components that it needs to be consolidated into a single function.
This function maps out the possible state updates; a reducer.
Usually reducers are half state-machines with long a switch statement with a case for each action type (or many if/elses, same thing). Maybe your reducer is particularly gnarly and has few nested conditions, oh boy.
It’s easy to think that useReducer
is just for managing complex state but it isn’t necessarily.
I’d say it’s for encapsulating state updating logic and consequently restricting how state can be updated 2 3.
As such, it can be used for simple state updates too, very simple updates in fact, so simple that the reducer only needs a single action.
This can be useful if you have some state that always updates in the same way.
Reducer Refresher
Let’s refresh what a reducer is and what it looks like. A reducer is a function that computes new state based on the action it receives and the current state.
function reducer(state, action) { if (action.type === "delete_first") { const newStateA = state.slice(1); return newStateA; } else if (action.type === "delete_last") { const newStateB = state.slice(0, -1); return newStateB; } else { return state; }}
The state
argument is passed automatically by React, the action
is the argument passed to the dispatch function.
Consider the very common toggle state, state that can only flip between true
and false
.
Let’s look at a small state diagram:
Pretty simple, here’s how it’s done with useState
.
const [isOpen, setIsOpen] = useState(false);const toggleOpen = useCallback( () => setIsOpen((prevIsOpen) => !prevIsOpen), []);// in event handlers or effectstoggleOpen();
To use toggleOpen
in a useEffect
, it has to be wrapped in useCallback
to make it stable to avoid a runaway effect.
And sure, that works, but it’s quite some boilerplate for something as simple as flipping a switch.
Now let’s try to write it as a simple reducer instead.
The state updating logic here is (prevIsOpen) => !prevIsOpen
.
This is our reducer! You may think Wait, doesn’t a reducer need two arguments? Where’s the
action
argument?
There’s only one possible action here; toggling.
No need for an action
argument, it’s an actionless (well, single-action really) reducer.
We can just call the dispatch function without an action argument.
const [isOpen, toggleOpen] = useReducer((prevIsOpen) => !prevIsOpen, false);// in event handlers or effectstoggleOpen();
We achieve the exact same in a single line. The dispatch function (toggleOpen
) is stable, so keep your useCallback
, no need for it here.
Also this restricts setting state explicitly. For a real world example, suppose you’re working in a team on a gigantic app and pass a state-setter function for a toggle down to parts of the app that fall under your teammates responsibilty. They can now set the state to whatever, which is not what a toggle should be able to do! OK, this might be slightly farfetched but you get what I mean, limiting possibilites is good design✨.
There’s an even simpler variant of a boolean reducer; a ‘one-way’ boolean state that can only be enabled. It doesn’t take any arguments, it’s both actionless and stateless (more on stateless reducers later).
const [isEnabled, enable] = useReducer(() => true, false)
I made a rough start with this blog post when I saw this snippet shared by Mark Dalgleish on Twitter. This is what he has to say about it.
Using
useReducer
to manage one-way boolean state has become one of my favourite tricks lately. I just used this for knowing that an image has loaded - once it’s loaded, it doesn’t unload. [It’s] more secure since I never want its value to revert. Also, [it] means I can pass the setter directly to event handler props and it’ll be the same on every render, e.g.onLoad={setLoaded}
Thanks Mark. That’s a very cool trick and an excellent example of using a reducer to limit possible updates. In the same vein, I can imagine a one-way boolean reducer can be used to hide a succesfully submitted form.
Let’s look at a small state diagram for it again:
In state machine parlance, calling enable
‘transitions to the final state’; true
.
Not coincidentally, David K. — spiritual father of XState, a JavaScript state machine library — responded to Mark’s tweet with tame approval with a I don’t hate this
.
Another classic type of state is a counter, although I think it’s more common in examples than in actual apps. Anyway, here’s how that’s done as an actionless reducer.
const [count, increment] = useReducer(c => c + 1, 0)increment()
This reducer is for cycling through an array using the modulo operator. Josh Comeau has written nice post explaining how it works: Understanding the JavaScript Modulo Operator.
const options = ["JavaScript", "TypesSript", "Swift"];const [index, next] = useReducer(i => (i + 1) % options.length, 0)const currentOption = options[index]; // "javascript"next() // "typescript"next() // "swift"next() // "javascript"
Optional action reducers are one step up from actionless reducers. They’re great for state that updates in some way most of the time, but sometimes needs to be set explicitly. In that case, you can pass the new state as the action.
We can expand the toggle reducer by optionally passing the next state as the action. If no action is passed, it toggles. To explicitly set a state, pass it to dispatch function.
// the action represent the explicit next state hereconst toggleReducer = (state, nextState) => { if (nextState === undefined) { return !state; // toggle if nothing passed to dispatch } else { return nextState; }};const [isOpen, toggleOpen] = useReducer(toggleReducer, false);toggleOpen(); // flip statetoggleOpen(false); // explicitly set state to falsetoggleOpen(true); // explicitly set state to true
Or as a one-liner using the nullish coalescing operator.
const toggleReducer = (state, nextState) => nextState ?? !state;
If toggling is the more common type of update, I think this is slightly nicer than calling a state updater function again and again.
For the count reducer, we can make the action argument optional by assigning it a default value of 1
.
To add or subtract some other number, pass it to the dispatch function (increment
) .
const [count, increment] = useReducer((c, add = 1) => c + add, 0);increment(); // adds 1 to the countincrement(5); // adds 5 to the countincrement(-5); // subtract 5 from count, but then increment isn’t the best name anymore
Stateless reducers ignore the state
argument and only use the action
. I think these aren’t as interesting as actionless reducers, they’re simply reusable switches that always does the same thing with a given action.
They can come in handy if state should be structured a certain way and you want to avoid the boilerplate of writing that structure over and over.
For example, this asyncReducer
for an useAsync
Promise helper hook (source) always returns an object with only status
, data
, and error
keys, with the data from the action
as values.
function asyncReducer(state, action) { // state is unused in this reducer switch (action.type) { case "pending": { return { status: "pending", data: null, error: null }; } case "resolved": { return { status: "resolved", data: action.data, error: null }; } case "rejected": { return { status: "rejected", data: null, error: action.error }; } default: { throw new Error(`Unhandled action type: ${action.type}`); } }}function useAsync(initialState) { const [{ data, error, status }, dispatch] = useReducer(asyncReducer, { status: "idle", data: null, error: null, ...initialState, }); const run = useCallback( (promise) => { dispatch({ type: "pending" }); promise.then( (data) => { dispatch({ type: "resolved", data }); }, (error) => { dispatch({ type: "rejected", error }); } ); }, [dispatch] ); return { error, status, data, run };}
Also see my follow-up post on the benefits of slightly larger reducers for common types of states.
-
↩
Components with many state updates spread across many event handlers can get overwhelming. For these cases, you can consolidate all the state update logic outside your component in a single function, called a reducer.
React Docs: Extracting State Logic into a Reducer -
↩
I like using using reducers to encapsulate the logic in one place. All the outside can do is dispatch messages and read the current state. I really like using callback
Tweet by Patrick SmithsetState
, but usually only if there’s a single call of it, if there’s more I’ll refactor touseReducer
. -
↩
Also passing dispatch down is a better idea than passing a setter because it restricts what child can do.
Tweet by Dan Abramov -
↩
[A]s an escape hatch, you can use an incrementing counter to force a re-render even if the state has not changed. Try to avoid this pattern if possible.
React Hooks FAQ: Is there something like forceUpdate?