DEV Community

loading...

Think you need to useReducer? You might want to useMethods instead

Tom Crockett
I like to polish small nuggets of code to a bright sheen
・4 min read

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.memoized!

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.memoizing 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 for useCallback 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

Discussion (1)

Collapse
bigo104 profile image
bigo104

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?