The useReducer hook: making React components testable and maintainable

Categories React Hooks
Photo by Camilo Jimenez

The reduce pattern

useReducer is a React hook that helps to apply functional programming reduce pattern to managing the state of React UI component.

The idea is to use reduce function to model a UI. Remember the reduce function from JavaScript? Here is an example of how to calculate an array’s total using reduce.

// Signature: arr.reduce(callback(accumulator, currentValue[, index[, array]]), [, initialValue])
var total = [ 0, 1, 2, 3 ].reduce(
  ( accumulator, currentValue ) => accumulator + currentValue,
  0
);

It executes a reducer function (in this example ( accumulator, currentValue ) => accumulator + currentValue) on each element of the array, resulting in single output value.

The UI state can be modeled using reduce as well. Replace

  • an array [ 0, 1, 2, 3 ] with an array of events that happen on UI,
  • initial value 0 with the model of the initial state of the UI and
  • total calculating reducer with reducer that takes the current state of the UI (accumulator) and an event from the input array returning a new state of the UI.

Such a replacement gives us a neat functional-oriented model of UI were having a reducer and initial state you can calculate new UI state in response to any sequence of events.

Example. Let’s say we have a pagination with prev and next buttons and numeric buttons which direct pagination to the specific page. The number of pages is limited to 1-10. The reducer for this pagination might be the following:

function reducer(state, event) {
  if (typeof event === "number") return event;
  if (event === "prev") return Math.max(state - 1, 0);
  if (event === "next") return Math.min(state + 1, 10);
  return state;
}

Now, we can calculate what the page will be if we start from the page 10 and then the following sequence of events occurs: [4, “prev”, “next”, “next”]:

[4, "prev", "next", "next"].reduce(reducer, 10); // 5

useReducer hook

const [state, dispatch] = useReducer(reducer, initialArg, init);
  • reducer – the reducer of type (state, action) => newState. The only difference from what we discussed before is the naming. When talking about reducers in React we usually use action term instead of an event because event term is used already for a different purpose in React and DOM.
  • initialArg – initial state
  • init – a function to calculate initial state in those cases when initialization needs to be lazy. When React invokes this function it passes initialArg into it.

useReducer example

Now, let’s reuse the reducer we have already developed and create a pagination component using it and useReducer.

function Pagination() {
  const [page, dispatch] = React.useReducer(reducer, 1);
  const pages = [...new Array(10)].map((_, i) => i + 1);

  return (
    <section className="App">
      <div>{page}</div>
      <footer>
        <button onClick={() => dispatch("prev")}>-</button>
        {pages.map(p => (
          <button
            key={p}
            className={page === p ? "page selected" : "page"}
            onClick={() => dispatch(p)}
          >
            {p}
          </button>
        ))}
        <button onClick={() => dispatch("next")}>+</button>
      </footer>
    </section>
  );
}

useReducer takes our reducer and the initial page. Subscribes Pagination component to changes of the state, managed by useReducer. Every dispatch triggers new reducer run and when the new state is obtained Pagination component is scheduled for re-render with this new state. If reducer produces the same state as before (defined by Object.is comparison), the new render does not happen.

In this example, dispatch takes string or number for simplicity but of course, it could be in the form or Redux action like {type:'PRESSED_NEXT'}, {type:'PRESSED_PREV'}, {type:'PRESSED_PAGE', payload:5}. In reducer, we would do a switch statement over the type of action just like every Redux reducer does.

Find a working example here.

Discussion

There are few important benifits for useReducer

  • Manages complexity. If a component has a complex logic and many state transitions, reducer pattern is a perfect solution to simplify the code. Because in every section of reducer you can focus on handling a particular case: given this state and this event happens, where the state should transition. Essentially this is a transition function from a state machine model.
  • Testability. Since a reducer is a pure function it is very easy to export it and test with a unit testing framework. By testing a reducer we can cover whole state transition logic with fast unit tests and reduce the risk of having this logic regress. This brings a lot of value as state transition logic is usually a business logic of the app.
  • Performance optimization. dispatch function does not change on the component re-renders. This makes it a good candidate to be passed as a callback down the component tree without breaking optimizations of React.memo and PureComponent.
  • Predictable state management. Here is how Dan Abramov explains it. Replace “all the state of your application” with “all the state of your component” and this will become an explanation of why useReducer makes component state predictable:

It’s a “state container” because it holds all the state of your application. It doesn’t let you change that state directly, but instead forces you to describe changes as plain objects called “actions”. Actions can be recorded and replayed later, so this makes state management predictable. With the same actions in the same order, you’re going to end up in the same state.

FacebooktwitterlinkedinFacebooktwitterlinkedin

Leave a Reply

Your email address will not be published.