Medium Reducers in React

Published

This is a follow-up to my "Actionless and Stateless reducers" post. There, I wrote how I think useReducer is underutilized and how it's great for encapsulating state updating logic no matter how simple the state. You can even use it without reading the reducers action or state parameter.

But there's a wide middle ground between very simple and complex multi-value state updates. Think of interactions that require just a bit of logic. Let's consider reducers for this more mundane middle, reasonably-sized reducers that don't need many nested conditions but also aren't simple enough to be actionless — rather, they're medium reducers for every-day state updates.

useState and useReducer are interchangeable, with useState being the 'simpler' and more common of the two. So why not useState?

Some people like reducers, others don’t. That’s okay. It’s a matter of preference. You can always convert between useState and useReducer back and forth: they are equivalent!

React Docs

When state updating logic is spread out over multiple event handlers, it can be hard to reason about what a state can or cannot be. More relevant to medium reducers, when a state has some , mistake are made more easily when logic is repeated in multiple event handlers. The logic should be sound on each update to avoid impossible states.

I think useState is also less convenient for arrays and objects. These have to 1, that can get a little tedious. Again, this becomes especially annoying when it's repeated a couple of times.

You're smart and instead of repeating state updating logic in event handlers, you make a a function (or multiple) to organize state updating logic — a wrapped state setter — and pass that instead of the setter. Good move! That can guard state too, if you make sure to not use the state setter anywhere else that is.

I tend to avoid callbacks for state updating logic, because I think, callbacks come with drawbacks in React.

  • Each callback has to be passed as a prop down the tree which. With multiple callbacks, this becomes annoying quickly.
  • A callback ties logic to the scope of the component it is defined in (due to closure). It's difficult to remember which props or state are used by functions defined far away.
  • Functions defined in components are . To use callbacks in hooks with dependency arrays or pass them to memoized components, they have to be memoized with useCallback. Don't lie to React or the linter, include functions in deps 2 3.

These are not problems per sé. I find that callbacks just make everything harder, this become more apparant in large code bases. Given enough time, you will reach a point where you'll wonder "Will adding this callback to this dependency array blow up a useEffect?". Or you'll notice stale values because a callback was omitted from deps somewhere. You'll have to trace back where it came from and that's no fun.

With useReducer, all that logic is snug inside the reducer, no need for callbacks, just call dispatch and the update will be handled. Because state and state logic are all wrapped up in one neat package, it's easier to see all possible updates and consequently, all possible states at a glance.

And because the reducer encapsulates updates, it embeds constraints so they can't be violated, making impossible states actually impossible.

Another benefit is that you only need to pass dispatch, which is guaranteed to be stable 4. That's why the React docs also recommended useReducer and passing dispatch over many callbacks 5 6.

So summarizing, I think there are two situations where medium reducers can be useful.

  1. State with constraints: A reducer embeds those constraints.
  2. State updates that are tedious: A reducer defines the updates once so it avoids repeated tedium.

With that in mind, let's look at three practical examples of medium reducers. Each example starts with a live example, go ahead and click around!

This is a beautifully simple reducer by Patrick Smith for a very common type of state, the open state for multiple UI items like menus or dialogs of which only one can be open at a time. Clicking an UI element should open it, fow for the constraint: clicking an already open item should close it.

type Menu = null | "file" | "edit" | "view"; // null means closed

function menuReducer(state: Menu, action: Menu) {
  if (action === state) {
    return null; // Close if it's already open
  }

  return action; // Use passed menu name
}

const [openMenu, dispatchMenu] = useReducer(menuReducer, null);

Or more tersely, using a ternary operator and placing the reducer inline.

const [openMenu, dispatchMenu] = useReducer(
  (state, action) => state === action ? null : action, // the reducer
  null // the initial state
);

There are no in this reducer, the action is the new state. If it's the same as the currently open one, return null to close it, otherwise set it to state. It’s just like a useState() setter but the check is conveniently baked in. This prevents impossible states, like multiple menus open at the same time.

On boolean states

If you're using a boolean state for each menu, simplify and avoid impossible states by condensing all those states into a single state; a string of the open menu. Only one menu can be open at any time, so using multiple boolean states is redundant. More importantly it's potentially buggy, you could open one menu but forget to close the other and now two menus are open at the same time 7.

See Choosing the State Structure: Avoid contradictions in state in the React docs.

For completeness, here's what it would look like with useState:

const [openMenu, setOpenMenu] = useState(null);

// reading the state variable
const dispatchMenu = (menu) => {
  setOpenMenu(openMenu === menu ? null : menu);
}

// or using a state updater function
const dispatchMenu = (menu) => {
  setOpenMenu((prevOpenMenu) => (prevOpenMenu === menu) ? null : menu);
}

Espresso Martini1: Sugar syrup

Put the caster sugar in a small pan over a medium heat and pour in 50ml water. Stir, and bring to the boil. Turn off the heat and allow the mixture to cool.

Next up, a step state. Say we have some steps of a recipe to display sequentially. The state is simply the index of the currently visible step. The step can't go beyond the first or last. That's the constraint, it really needs to checked when setting state, disabling a button is not enough.

The possible interactions are 1) going to the next or 2) previous page, or 3) jump to a step directly with a number input. next and previous are events that make new state based on prior state, while passing a number directly sets a new state, as long as it's within bounds.

This time, let's look at how this would be done with useState first.

const [stepIndex, setStepIndex] = useState(0);

const goToPreviousStep = () => {
  const prevStepIndex = Math.max(0, stepIndex - 1);
  setStepIndex(prevStepIndex); // or using state updater `setStepIndex(s => Math.max(0, s - 1))`
};

const goToNextStep = () => {
  const nextStepIndex = Math.min(steps.length, stepIndex + 1);
  setStepIndex(nextStepIndex);
};

const goToStep = (newStepIndex) => {
  if (stepIndex >= 0 && stepIndex <= steps.length) {
    setStepIndex(newStepIndex);
  }
};

A callback wrapping the state setter with 'guard logic' for each interaction. Note that goToStep depends on data from props; steps.length. If goToStep was wrapped in useCallback, steps.length should be included in the deps.

For a reducer to access props (or state), it can be placed in the React component 8. Even when the reducer reads data from props or state, dispatch will still always be stable 9.

function Steps({ steps }) {
  function stepReducer(stepIndex, action) {
    if (action === "previous") {
      const prevStepIndex = Math.max(0, stepIndex - 1);
      
      return prevStepIndex;
    } else if (action === "next") {
      const nextStepIndex = Math.min(steps.length, stepIndex + 1);

      return nextStepIndex;
    } else if ( // check if new step is within bounds
      typeof action === "number" &&
      action >= 0 &&
      action <= steps.length - 1
    ) {
      return action;
    }

    return stepIndex
  }

  const [stepIndex, dispatchStep] = useReducer(stepReducer, 0);

  return <div>{/* More JSX here */}</div>;
}

It is an 'event-driven reducer' (previous & next) with an escape hatch to set state directly but with 'guards' (passing a number).

Tip for the number `<input/>`

Make the 'jump to step' number <input/> uncontrolled and set the value in the onBlur handler. Reset the component by passing the current stepIndex to the key attribute. onChange in combination with a key resets focus state on each change, which is not nice.

As a controlled component, only values within the allowed range can be entered and making the field empty when entering input is now allowed. That makes it a tad confusing.

Whereas the uncontrolled version will allow out-of-range numbers to be entered and displayed, but it won't do anything with it. The key with onBlur approach provides better UX than a controlled component.

And a small CSS tip: Display a warning color when an out-of-bounds value is entered using the :out-of-range CSS pseudoselecter.

 <input
  type="number"
  key={stepIndex}
  min={0}
  max={steps.length}
  defaultValue={stepIndex}
  onBlur={(e) => {
    const newStepIndex = Number(e.target.value);
    dispatchStep(newStepIndex);
  }}
/>

If you think that placing the reducer inline clutters up the component, which I do, you can turn it into a higher-order reducer. Wrap it in a function that takes the necessary data from props or state (here steps.length) as a parameter and move it out of the component. I prefer arrow syntax for :

const stepReducer = (length) => (stepIndex, action) {
  if (action === "previous") {
    const prevStepIndex = Math.max(0, stepIndex - 1);

    return prevStepIndex;
  } else if (action === "next") {
    const nextStepIndex = Math.min(length - 1, stepIndex + 1);

    return nextStepIndex;
  } else if (
    // check if new step is within bounds
    typeof action === "number" &&
    action >= 0 &&
    action <= length - 1
  ) {
    return action;
  }

  return stepIndex;
}

export function Steps({ steps }) {
  const [stepIndex, dispatchStep] = useReducer(stepReducer(steps.length), 0);
  // Much cleaner!

  const currentStep = steps[stepIndex];

  return <div>{/* More JSX here */}</div>;
}

Neat! Inline reducers are powerful and can do some things that useState nor any state management library 10 but that's something for another post.

John
Jim
Jules
Jack
Josh
Yes I know, showing both add & remove buttons is stupid but it's for the sake of example, as is not disabling the buttons.

How often are you handling an array of strings in state? Me, quite often. I usually prefer a mid-sized reducer over useState for it.

Let's imagine we've got an array of selected names in state, a name can be added or removed by clicking a button. Only one name can be added or removed at a time. There's another button to clear all selected names. To spice things up, let's add a constraint; no duplicates are allowed.

Let's write a mid-sized reducer for it alright. It'll be a traditional 'event-driven reducer' with the action argument being an object with a type property and some data, the name in this case.

function namesReducer(state, action) {
  const { type } = action;

  switch (type) {
    case "add": {
      if (state.includes(action.name)) {
        return state; // do nothing if the name is already selected
      }
      return [...state, action.name];
    }
    case "remove": {
      return state.filter((d) => d !== action.name);
    }
    case "clear": {
      return [];
    }
    default:
      return state;
  }
}

It's a modest three-case reducer. Even with just a few events, it still pays to write a medium reducer to encapsulate and restrict state.

TypeScript Tip

TypeScript makes reducers even sweeter to work with. TypeScript can give give hints and warnings about the actions and possible states. See Type-checking React useReducer in TypeScript by BenMVP for more.

If you think an object as the action parameter is too verbose, you can pass an array with [type, name] too. This is shorter but more implicit. You know what the array stands for but it can be confusing to other people working in the codebase.

Tip for people who are lazy and want to confuse their coworkers (or work alone and like terse code): useReducer doesn’t actually force you to pass an object. [A] plain array works too dispatch(['add', 42]). In [the] reducer you can switch on action[0] and read out. const [, id] = action. I didn't tell you this was a good idea

Tweet by Dan Abramov
function namesReducer(state, action) {
  const [type] = action;

  switch (type) {
    case "add": {
      const [, name] = action;
      if (state.includes(name)) {
        return state; // do nothing if the name is already selected
      }
      return [...state, name];
    }
    case "remove": {
      return state.filter((d) => d !== action[1]);
    }
    case "clear": {
      return [];
    }
    default:
      return state;
  }
}

And finally, how names state would be done with useState:

// adding a name 
setSelectedNames((s) => s.includes("Jules") ? [...s, "Jules"] : s); 
// removing
setSelectedNames((s) => s.filter("John"); 
// clearing
setSelectedNames([]);

To summarize, useState is more direct, you're in full control of what the state should be. Sometimes that's convenient and sometimes not. You could make a callback to extract the state updating logic but that comes with the aforementioned downsides. Writing a reducer is more work upfront but it pays off in use, a worthwhile investment in my opinion.

The code examples in this post are available in a CodeSandbox.

  1. Arrays are mutable in JavaScript, but you should treat them as immutable when you store them in state. Just like with objects, when you want to update an array stored in state, you need to create a new one (or make a copy of an existing one), and then set state to use the new array. React Beta Docs: Updating Arrays in State

  2. It’s difficult to remember which props or state are used by functions outside of the effect. This is why usually you’ll want to declare functions needed by an effect inside of it. Then it’s easy to see what values from the component scope that effect depends on: [..] If you specify a list of dependencies as the last argument to useEffect, useLayoutEffect, useMemo, useCallback, or useImperativeHandle, it must include all values that are used inside the callback and participate in the React data flow. That includes props, state, and anything derived from them. It is only safe to omit a function from the dependency list if nothing in it (or the functions called by it) references props, state, or values derived from them. React docs: Hooks FAQ — Is it safe to omit functions from the list of dependencies? (1)

  3. Lying to React about dependencies has bad consequences. Intuitively, this makes sense, but I’ve seen pretty much everyone who tries useEffect with a mental model from classes try to cheat the rules. (And I did that too at first!) “But I only want to run it on mount!”, you’ll say. For now, remember: if you specify deps, all values from inside your component that are used by the effect must be there. Including props, state, functions — anything in your component. Sometimes when you do that, it causes a problem. For example, maybe you see an infinite refetching loop, or a socket is recreated too often. The solution to that problem is not to remove a dependency. A Complete Guide to useEffect - Don’t Lie to React About Dependencies

  4. The identity of the dispatch function from useReducer is always stable — even if the reducer function is declared inside the component and reads its props. React docs: Hooks FAQ — Is it safe to omit functions from the list of dependencies? (2)

  5. In large component trees, an alternative we recommend is to pass down a dispatch function from useReducer via context: This is both more convenient from the maintenance perspective (no need to keep forwarding callbacks), and avoids the callback problem altogether. Passing dispatch down like this is the recommended pattern for deep updates. React Docs Hooks FAQ: How to Avoid Passing Callbacks Down

  6. Dispatch is better than helper methods. Helper methods are object junk that we need to recreate and compare for no purpose other than superficially nicer looking syntax. There’s no reason to literally pass more things down when you could’ve passed dispatch alone, and then use it directly (or pass it to more things). Passing raw dispatch down also makes code splitting such "async methods" easier. You import them from the leaf component that uses them instead of the root component. So you only pay for what you use where you use it. Tweets by Dan Abramov

  7. [On using multiple boolean states]. While this code works, it leaves the door open for “impossible” states. For example, if you forget to call setIsSent and setIsSending together, you may end up in a situation where both isSending and isSent are true at the same time. The more complex your component is, the harder it will be to understand what happened. Since isSending and isSent should never be true at the same time, it is better to replace them with one status state variable that may take one of three valid states: 'typing' (initial), 'sending', and 'sent': React Beta Docs: Choosing the State Structure - Avoid contradictions in state

  8. We’ve seen how to remove dependencies when an effect needs to set state based on previous state, or on another state variable. But what if we need props to calculate the next state? [...]. Surely, in [that] case we can’t avoid specifying props.step as a dependency? In fact, we can! We can put the reducer itself inside our component to read props: This pattern disables a few optimizations so try not to use it everywhere, but you can totally access props from a reducer if you need to. A Complete Guide to useEffect - Why useReducer Is the Cheat Mode of Hooks

  9. Even in [when a reducer is placed inline], dispatch identity is still guaranteed to be stable between re-renders. So you may omit it from the effect deps if you want. It’s not going to cause [an] effect to re-run. You may be wondering: how can this possibly work? How can the reducer “know” props when called from inside an effect that belongs to another render? The answer is that when you dispatch, React just remembers the action — but it will call your reducer during the next render. At that point the fresh props will be in scope, and you won’t be inside an effect. This is why I like to think of useReducer as the “cheat mode” of Hooks. It lets me decouple the update logic from describing what happened. This, in turn, helps me remove unnecessary dependencies from my effects and avoid re-running them more often than necessary. A Complete Guide to useEffect - Why useReducer Is the Cheat Mode of Hooks

  10. One thing that’s notable is that useReducer is strictly more powerful (which can lead to fewer optimizations). It can do something that no third-party [library] nor useState can. It can respond to changes to a parent in the same batch, has already been queued or happens at higher pri[ority]. Sebastian Markbåge tweet