The power of useReducer
is well-documented. It is the fundamental building block of all state management in React Hooks, so ultimately any hook-based state management depends on it. But it's worth asking, is it the best API we could come up with? One must admit that it forces us to write our logic in a fairly awkward style.
Let's take a look at a small example. The Counters
component renders a list of counters, each of which you can either increment or clear, and a button to add a new counter at the end.
const Counters = () => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
<Button onClick={() => dispatch({ type: 'ADD_COUNTER' })}>add counter</Button>
{counters.map(({ id, count }) => (
<Counter
key={id}
count={count}
onIncrement={() => dispatch({ type: 'INCREMENT_COUNTER', id })}
onClear={() => dispatch({ type: 'CLEAR_COUNTER', id })}
/>
))}
</>
);
};
const initialState = {
nextId: 0,
counters: [],
};
const reducer = (state, action) => {
switch (action.type) {
case 'ADD_COUNTER': {
const nextId = state.nextId + 1;
return {
nextId,
counters: [...state.counters, { id: nextId, count: 0 }],
};
}
case 'INCREMENT_COUNTER': {
const index = state.counters.findIndex(counter => counter.id === action.id);
const counter = state.counters[index];
return {
...state,
counters: [...state.counters.slice(0, index), { ...counter, count: counter.count + 1 }],
};
}
case 'CLEAR_COUNTER': {
const index = state.counters.findIndex(counter => counter.id === action.id);
const counter = state.counters[index];
return {
...state,
counters: [...state.counters.slice(0, index), { ...counter, count: 0 }],
};
}
}
};
Some things to note about this:
All of your logic is in a single switch
statement
In this toy example it doesn't look too bad, but you can imagine that with a few more actions it could start to get cumbersome and you'd probably want to extract separate functions which the switch statement would call out to.
Each case must return a new version of the state
Conceptually what we want to do in INCREMENT_COUNTER
is just... increment a counter! The simplest thing in the world. But because the state is immutable, we need to jump through all kinds of hoops to produce a new copy. And that's not the end of our problems, because...
It's up to you to make sure you achieve sharing in your data structures
That is, if conceptually an action should have no effect given the current state, it's up to you to make sure you return the same state, not just a new one which is structurally equal, or else it may cause unnecessary rendering. And in this case we're failing to do that, specifically in the CLEAR_COUNTER
case. If the counter was already 0
at the given index, clearing it should have no effect, but our code will create a whole new array and re-render all our Counter
children, even if they're React.memo
ized!
It's up to you to convert dispatch
to callbacks
At some point, you need to convert your dispatch
function to callbacks, and that's both awkward and also tends to spoil memoization. Here we are passing new arrow functions to the Button
and Counter
components every single time we render. So again, React.memo
izing them will be useless. The standard options for solving this problem are either to just pass down the entire dispatch
function to these sub-components, giving the child the keys to the castle and forcing them to be specialized to the parent's use-case, or make a callback using useCallback
.
Solution: useMethods
I'll cut to the chase: there's a better way, and it's called useMethods
. Here's how we would rewrite the above example with it:
const Counters = () => {
const [
{ counters },
{ addCounter, incrementCounter, clearCounter }
] = useMethods(methods, initialState);
return (
<>
<Button onClick={addCounter}>add counter</Button>
{counters.map(({ id, count }) => (
<Counter
key={id}
id={id}
count={count}
onIncrement={incrementCounter}
onClear={clearCounter}
/>
))}
</>
);
};
const initialState = {
nextId: 0,
counters: [],
};
const methods = state => ({
addCounter() {
state.counters.push({ id: state.nextId++, count: 0 });
},
incrementCounter(id) {
state.counters.find(counter => counter.id === id).count++;
},
clearCounter(id) {
state.counters.find(counter => counter.id === id).count = 0;
},
});
Looks quite a bit cleaner, right? Things to note:
- Logic is now nicely encapsulated in separate methods, rather than in one giant
switch
statement. Instead of having to extract a "payload" from our action object, we can use simple function parameters. - We can use the syntax of mutation to edit our state. It's not actually editing the underlying state but rather producing a new immutable copy under the hood, thanks to the magic of
immer
. - Instead of getting back a one-size-fits-all
dispatch
function, we get back a granular set of callbacks, one for each of our conceptual "actions". We can pass these callbacks directly to child components; they are only created once so they won't spoil memoization and cause unnecessary rendering. No need foruseCallback
unless we need a callback which doesn't already map directly to one of our state-changing actions!
Conclusion
Next time you need the full power of useReducer
, you might consider reaching for useMethods
instead. It's equally as expressive but with none of the clunky action baggage, and with great performance characteristics out of the box.
Give it a try: https://github.com/pelotom/use-methods
Here's the full working example of the code from this post: https://codesandbox.io/s/2109324q3r
Top comments (1)
How would you apply such logic to a shared component state, where comp1 and comp2 which live on opposites side of the tree need access to both actions and reduced data?