A Pattern for State Management in React Function Components

When creating a component in React, I often want it to be independent, able to maintain its own state. And at the same time, I’d like its parent to be notified when the component’s state changes, and I want to enable the parent to pass down new state at any time.

Implementing this behavior in a React function component just requires the right application of hooks. A simple component for adding fruit to a bag might accept props like this:

type Props = {
  fruits: Fruit[];
  onChange: (fruits: Fruit[]) => void;
};

State goes into the component through the fruits prop, and changes are communicated back up to the parent through the onChange callback. Importantly, we only want the onChange callback to be fired when the component itself is updating the state (usually in direct response to user input) — not when the parent has just sent in new state. Doing this should break most of those nasty infinite update loops.

Inside the component, state can be managed with useState or useReducer. useReducer may be preferred when there’s a lot of state or when updating it gets complicated. It also makes this discussion more interesting, so I’ll use it in the following examples (even though the state is pretty simple).

Say our initial FruitBag component looks like this:

export type Fruit = "Apple" | "Orange" | "Pear";
const allFruits: Fruit[] = ["Apple", "Orange", "Pear"];

type Props = {
  fruits: Fruit[];
  onChange: (fruits: Fruit[]) => void;
};

type State = {
  fruits: Fruit[];
};

export const FruitBag: React.FunctionComponent<Props> = ({
  fruits,
  onChange
}) => {
  const [state, dispatch] = useReducer(reducer, { fruits });

  return (
    <div>
      <div>Fruits in bag: {fruits.join(", ")}</div>
      <ul>
        {allFruits.map(fruit => (
          <li key={fruit}>
            <button onClick={() => dispatch({ type: "addFruit", fruit })}>
              Add
            </button>
            <button onClick={() => dispatch({ type: "removeFruit", fruit })}>
              Remove
            </button>
            {fruit}
          </li>
        ))}
      </ul>
    </div>
  );
};

type Action =
  | { type: "addFruit"; fruit: Fruit }
  | { type: "removeFruit"; fruit: Fruit };

const reducer: Reducer<State, Action> = (prevState, action) => {
  switch (action.type) {
    case "addFruit":
      return {
        ...prevState,
        fruits: [...prevState.fruits, action.fruit]
      };

    case "removeFruit":
      return {
        ...prevState,
        fruits: _.remove(prevState.fruits, f => f !== action.fruit)
      };
  }
};

Currently, FruitBag will accept the list of fruits given to it when the component is mounted, and it will update its own list when fruit is added or removed. But we still want it to notify the parent of changes using onChange and accept an updated list of fruits at any time.

Notify Parent Using onChange

Let’s start with notifying about changes. We could put the call to onChange right after dispatch, like so:

<button onClick={() => {
    dispatch({ type: "addFruit", fruit });
    props.onChange(state.fruits);
}}>

But this won’t work the way we want it to. It will send the previous list of fruits, before the update. This is because dispatch is asynchronous — not in the same sense as async/await and Promises, but simply due to the nature of React’s component render cycle. The state returned from useReducer is the actual state value; it’s not a getter or a reference, so it doesn’t have the updated value until the next render cycle.

To get around this, you might be tempted to include a call to onChange directly within your reducer. But since we have no idea what the callback might do, we have to treat it like a side effect. And reducers must only produce an updated state without causing any side effects.

In a function component, the right place for side effects is in a useEffect hook. So let’s add one of those to notify whenever state changes:

  useEffect(() => {
    onChange(state.fruits);
  }, [state]);

This seems simple enough: useEffect will run this function after rendering, but only if state has changed since the last render.

Of course, if you’re using a linting tool like ESLint, you’ll get a warning like this:

React Hook useEffect has a missing dependency: ‘onChange’. Either include it or remove the dependency array. If ‘onChange’ changes too often, find the parent component that defines it and wrap that definition in useCallback.

useEffect prefers that the dependency array include all of the stateful things referenced by the function. In this case, that includes state as well as onChange, which is passed in through props. Since we are only interested in calling onChange when state changes, this code will work correctly as is.

And adding onChange to the dependency array may actually introduce subtle bugs if the parent sets up onChange incorrectly. For instance, say the parent component did this:

  return (<FruitBag fruits={fruits} onChange={newFruits => console.log(newFruits)} />);

This doesn’t seem too unreasonable. But since onChange is actually getting a new function every time this parent component renders, it will be fired redundantly (and even erroneously, since the intention is only to call onChange when FruitBag updates its state).

One option would be to suppress the lint warning since we know what we’re doing. But since lint warnings are there for a good reason, the other option would be to do what it says and add onChange to the list of dependencies:

  useEffect(() => {
    onChange(state.fruits);
  }, [state, onChange]);

In order to get the expected behavior, this will also require anything using FruitBag to memoize onChange like so:

  const handleChange = useCallback(newFruits => {
    console.log(newFruits);
  }, []);

  return (<FruitBag fruits={fruits} onChange={handleChange} />);

Perhaps this isn’t a bad habit to get into. Similarly to useEffect, useCallback will only do its thing if its dependencies have changed. In this case, the dependency array is empty [], so it will only run once during this component’s lifecycle. handleChange will then be a stable function, meaning it won’t change from one render to the next.

Accept Updated State Through Props

Even if a component maintains its own internal state, it’s often useful to be able to update this state from a parent. This first line of the FruitBag component is a good start:

const [state, dispatch] = useReducer(reducer, { fruits });

This will initialize the reducer with state initially passed into the component, but it won’t update if the parent sends new fruits at a later time. Since this sounds like mutating state when something changes, another useEffect is in order. We’ll also add a reset action to the reducer to simply pass in the new state:

  useEffect(() => {
    dispatch({ type: "reset", fruits });
  }, [fruits]);

Here’s the complete FruitBag component with these changes:

export type Fruit = "Apple" | "Orange" | "Pear";
const allFruits: Fruit[] = ["Apple", "Orange", "Pear"];

type Props = {
  fruits: Fruit[];
  onChange: (fruits: Fruit[]) => void;
};

type State = {
  fruits: Fruit[];
  notify: boolean;
};

export const FruitBag: React.FunctionComponent<Props> = ({
  fruits,
  onChange
}) => {
  const [state, dispatch] = useReducer(reducer, fruits, init);

  useEffect(() => {
    dispatch({ type: "reset", fruits });
  }, [fruits]);

  useEffect(() => {
    if (state.notify) {
      onChange(state.fruits);
    }
  }, [state, onChange]);

  return (
    <div>
      <div>Fruits in bag: {fruits.join(", ")}</div>
      <ul>
        {allFruits.map(fruit => (
          <li key={fruit}>
            <button onClick={() => dispatch({ type: "addFruit", fruit })}>
              Add
            </button>
            <button onClick={() => dispatch({ type: "removeFruit", fruit })}>
              Remove
            </button>
            {fruit}
          </li>
        ))}
      </ul>
    </div>
  );
};

type Action =
  | { type: "addFruit"; fruit: Fruit }
  | { type: "removeFruit"; fruit: Fruit }
  | { type: "reset"; fruits: Fruit[] };

function init(initialFruits: Fruit[]): State {
  return { fruits: initialFruits, notify: false };
}

const reducer: Reducer<State, Action> = (prevState, action) => {
  switch (action.type) {
    case "reset":
      return init(action.fruits);

    case "addFruit":
      return {
        ...prevState,
        fruits: [...prevState.fruits, action.fruit],
        notify: true
      };

    case "removeFruit":
      return {
        ...prevState,
        fruits: _.remove(prevState.fruits, f => f !== action.fruit),
        notify: true
      };
  }
};

useReducer has been modified to accept an init function, which it uses internally to set the initial state. We’re also using it to process the reset action.

Notice that, unlike with onChange, we don’t have to add dispatch to the dependency array of useEffect. That’s because useReducer guarantees that dispatch is a stable function (likewise for the set function of a useState).

Another important change is the addition of a notify flag to state. We can use this to differentiate between state updates coming from within the FruitBag (notify: true) and updates coming in through props (notify: false). This ensures we only send onChange for internal changes.

The End

The state in this example was pretty simple. And this particular component would probably be better off not maintaining any internal state at all (just rendering based on props, sending updates through onChange, and waiting for the parent to send new props).

However, this pattern can improve a component’s reusability, and it can be very useful as state gets more complex.

Conversation
  • André says:

    Hi Brian. I like the useReducer approach.

    What do you think about having that state inside a context (react context), exposing the fruits and the dispatch method?

    (avoiding props on FruitBag and those useEffects)

    • Brian Vanderwal Brian Vanderwal says:

      Hi André, that would certainly work if you wanted to remove state management from this component. Although I would still pass the state and dispatch callback through props unless a context is really necessary (I prefer to reserve those for global-style resources that need to be shared among many components).

  • Comments are closed.