All Articles

State update functions in React

Let’s build a counter component as a React class component!

class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  increment() {
    const { count } = this.state;
    this.setState({ count: count + 1 });
  }

  render() {
    const { count } = this.state;
    return (
      <>
        <p>
          <label id="count-label">
            Count: <span aria-label="count-label">{count}</span>
          </label>
        </p>
        <button onClick={() => this.increment()}>Increment</button>
      </>
    );
  }
}

Here we have a class component with a simple state containing only one variable count whose value is displayed on render. Clicking the increment button simply calls setState on the component which, you guessed it, increments the count state variable. Checkout this Codesandbox to have a play with this component.

All straightforward right? What you might not have noticed though is that we have produced a difficult to trace and inconsistent bug.

Let’s checkout the React docs for setState:

“Think of setState as a request rather than an immediate command. For better perceived performance, React may delay it, and then update several components in a single pass.”

This means that updating state in this way is an asynchronous process.

What does that mean for us? Well, what if we were to quickly click the button twice so that the two setState operations get batched together into the same cycle?

We would find that the second call of increment would execute the line const { count } = this.state before the state has been updated by the first click. The second execution would increment the count variable based on stale state and hence count would be set to the wrong value.

This bug can lead to some inconsistent behaviour depending on how quickly the button is clicked. For example the following test clicks the button a number of times and asserts that the correct value of count is displayed.

test("Counter counts accurately", () => {
  render(<Counter />);

  expect(screen.getByLabelText(/Count/i)).toHaveTextContent(0);

  fireEvent.click(screen.getByText(/Increment/i));
  expect(screen.getByLabelText(/Count/i)).toHaveTextContent(1);

  fireEvent.click(screen.getByText(/Increment/i));
  expect(screen.getByLabelText(/Count/i)).toHaveTextContent(2);
});

We could run this test on a test environment in a CI pipeline and, depending on the state of the environment and number of other tests running, this test might run at different speeds.

As a result, sometimes the setState calls might get batched and sometimes they might not. Hence occasionally the test will fail with no indication as to why - this might lead a developer to incorrectly believe the test to be flaky when in fact it is highlighting a bug in the code!

You and I can’t click as fast as a computer so let’s add a delay between obtaining count from state and setting the new state to better illustrate the issue.

class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  increment() {
    const { count } = this.state;
    setTimeout(() => {
      this.setState({ count: count + 1 });
    }, 1000);
  }
  render() {
    const { count } = this.state;
    return (
      <>
        <p>
          <label id="count-label">
            Count: <span aria-label="count-label">{count}</span>
          </label>
        </p>
        <button onClick={() => this.increment()}>Increment</button>
      </>
    );
  }
}

We have told setTimeout to increment our state after a 1 second delay. Go ahead and checkout this codesandbox. Click the button twice within 1 second. We would expect the state to increment by 2 but instead what we see is the state only increment by one. This is because the two executions of increment obtained the same value for count and so both set the state to the same value.

A bug! And an inconsistent bug at that as the code appears to work correctly if the button is clicked more slowly.

So what can we do to fix the issue? Let’s turn back to the React docs - the setState function has the following definition.

setState(updater[, callback])

Where updater is a function with the signature:

(state, props) => newState

In our case, the next state of the count variable only depends on the previous so we won’t use props. Let’s put this in our code.

class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  increment() {
    setTimeout(() => {
      this.setState((previousState) => ({
        count: previousState.count + 1
      }));
    }, 1000);
  }

  render() {
    const { count } = this.state;
    return (
      <>
        <p>
          <label id="count-label">
            Count: <span aria-label="count-label">{count}</span>
          </label>
        </p>
        <button onClick={() => this.increment()}>Increment</button>
      </>
    );
  }
}

Now, when setState is executed, we are guaranteed to have the current state passed to our updater function, even when setState executions get batched. As a result we never update count based on stale state and we have fixed the bug! Try this codesandbox for the final fixed component.

To avoid this, follow this general rule:

Whenever you update state based on previous state, use an update function to avoid stale state problems.

Conclusion

Updating the state of a component based it’s old state can introduced difficult to trace bugs if not done correctly. If two setState operations get batched they may attempt to update the state based on the same previous value leading to the incorrect final state.

The React docs tell us that setState can use an updater function whose arguments are the current state and props of the component. This allows us to reliably and confidently update the state of the component.

Thanks for reading! Hopefully now you have an understanding of how to correctly use setState and can recognise a bug in your codebase you didn’t realise was there!