Actionless and Stateless Reducers in React

Published Last updated v2.0.3

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 spread out over many components that it needs to be consolidated, with the state updates mapped out in a single function; 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 it 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.


_13
function reducer(state, action) {
_13
if (action.type === "delete_first") {
_13
const newStateA = state.slice(1);
_13
_13
return newStateA;
_13
} else if (action.type === "delete_last") {
_13
const newStateB = state.slice(0, -1);
_13
_13
return newStateB;
_13
} else {
_13
return state;
_13
}
_13
}

Granted, this is an inane example.

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:

state diagram for toggle reducer

Pretty simple, here’s how it’s done with useState.


_9
const [isOpen, setIsOpen] = useState(false);
_9
_9
const toggleOpen = useCallback(
_9
() => setIsOpen((prevIsOpen) => !prevIsOpen),
_9
[]
_9
);
_9
_9
// in event handlers or effects
_9
toggleOpen();

To use toggleOpen in a useEffect, it has to be wrapped in useCallback to make it stable to avoid 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 parameter.


_4
const [isOpen, toggleOpen] = useReducer((prevIsOpen) => !prevIsOpen, false);
_4
_4
// in event handlers or effects
_4
toggleOpen();

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 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).


_1
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}

Tweets by Mark Dalgleish

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 can be used to hide a succesfully submitted form.

Let’s look at a small state diagram for it again:

state diagram for toggle reducer

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.


_3
const [count, increment] = useReducer(c => c + 1, 0)
_3
_3
increment()

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, pass the new state as the action.

We can expand the toggle reducer by optionally passing the explicit next state as the action. If no action is passed, it toggles. To explicitly set a state, pass it to dispatch function.


_14
// the action represent the explicit next state here
_14
const toggleReducer = (state, nextState) => {
_14
if (nextState === undefined) {
_14
return !state; // toggle if nothing passed to dispatch
_14
} else {
_14
return nextState;
_14
}
_14
};
_14
_14
const [isOpen, toggleOpen] = useReducer(toggleReducer, false);
_14
_14
toggleOpen(); // flip state
_14
toggleOpen(false); // explicitly set state to false
_14
toggleOpen(true); // explicitly set state to true

Or as a one-liner using the nullish coalescing operator.


_1
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) .


_5
const [count, increment] = useReducer((c, add = 1) => c + add, 0);
_5
_5
increment(); // adds 1 to the count
_5
increment(5); // adds 5 to the count
_5
increment(-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 a reusable switch 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.


_42
function asyncReducer(state, action) { // state is unused in this reducer
_42
switch (action.type) {
_42
case "pending": {
_42
return { status: "pending", data: null, error: null };
_42
}
_42
case "resolved": {
_42
return { status: "resolved", data: action.data, error: null };
_42
}
_42
case "rejected": {
_42
return { status: "rejected", data: null, error: action.error };
_42
}
_42
default: {
_42
throw new Error(`Unhandled action type: ${action.type}`);
_42
}
_42
}
_42
}
_42
_42
function useAsync(initialState) {
_42
const [{ data, error, status }, dispatch] = useReducer(asyncReducer, {
_42
status: "idle",
_42
data: null,
_42
error: null,
_42
...initialState,
_42
});
_42
_42
const run = useCallback(
_42
(promise) => {
_42
dispatch({ type: "pending" });
_42
promise.then(
_42
(data) => {
_42
dispatch({ type: "resolved", data });
_42
},
_42
(error) => {
_42
dispatch({ type: "rejected", error });
_42
}
_42
);
_42
},
_42
[dispatch]
_42
);
_42
_42
return { error, status, data, run };
_42
}

  1. 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

  2. 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 setState, but usually only if there’s a single call of it, if there’s more I’ll refactor to useReducer. Tweet by Patrick Smith

  3. Also passing dispatch down is a better idea than passing a setter because it restricts what child can do. Tweet by Dan Abramov

  4. [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?