Why use useState functional update even when everything seems to work


If you read React documentation on useState hook functional updates, you'll find a Counter component together with the following information:

function Counter() {
  const [count, setCount] = useState(0)
  return (
    <>
      Count: {count}
      <button onClick={() => setCount(0)}>Reset</button>
      <button onClick={() => setCount(c => c + 1)}>+</button>
      <button onClick={() => setCount(c => c - 1)}>-</button>
    </>
  )
}

The ”+” and ”-” buttons use the functional form, because the updated value is based on the previous value.

Now you may be wondering what problems exactly would happen if we used the normal setState form setCount(count+1), instead of the functional one. After testing, everything still seems to be working fine.

A first explanation

Confused by the result, you googled a bit and came across the following explanation:

User can click on + twice very quick, before the component can re-render, and in the second call the value of count would not be updated

This explanation is incorrect for the current use case. If + button is clicked twice, that will produce two click events. Given the + button has an event handler which increments the counter value when there's a click event, React will re-render twice. Once for each click event. Even if those two clicks happen before there's an opportunity to re-render.

We can prove this by quickly clicking twice on + button of the Counter below and watching the logs produced from it

function Counter() {
  const [count, setCount] = useState(0)
  useEffect(() => console.log("render"))
  return (
    <>
      Count: {count}
      <button onClick={() => setCount(0)}>Reset</button>
      <button
        onClick={() =>
          console.log("clicked") || setCount(count + 1) || syncWait(2000)
        }
      >
        +
      </button>
      <button onClick={() => setCount(count - 1)}>-</button>
    </>
  )
}

// Blocks main thread for a given amount of milliseconds
function syncWait(ms) {
  const date = new Date()
  while (new Date() - date <= ms) {}
}

If we do the experiment, we'll see that counter value will be 2, and we'll have the following logs:

  1. render - First render of the component before any click.
  2. clicked - Log from first click.
  3. render - First re-render of the component. This will appear 2 seconds after first click.
  4. clicked - Log from second click.
  5. render - Second re-render of the component. This will appear 2 seconds after second click.

As we can observe from the logs, React re-rendered twice and no increment to count was lost.

You might have heard that React batches setState calls so that it only re-renders once. And that is true. But it only applies to setState calls done in event handlers addressing the same event. Which is not our use case. You can read more about how React batches setState calls in the section batching of this post

A second explanation

So, what's the problem with implementing the counter component with the normal form of setState? From the perspective of code behavior, there's no problem at all. It works just fine. But developing software is not just about making it work, it's also about making it right.

By not using the functional form of setState when we're updating state based on the previous state, we're leaving behind code that, as time goes by and the code base changes, can easily create bugs. For instance, as a code base becomes more complex it's easy to lose track of where state is being updated, and we might be making multiple updates in response to the same event. This will result in lost updates. Also, when asynchronous operations get added into the mix, it's really easy to have bugs related to stale state if we're not using the functional form of setState.

The point is, using the functional setState form when wanting to update state based on previous state, is a better default than using the normal setState form. The normal form will work some times. The functional form will work every time the normal form works and also in other situations where it won't. So this little decision will move our code closer to be better prepared for future change.

If you're updating state based on previous state and wondering if you should use the functional form or the normal form, use the functional form.