Reductio ad absurdum: state desync in React

State update functions comparison
Last updated:
Table of Contents

Explanation

The React useState hook provides a reference to the previous state when setting a new state. So, when setting a new state that is based on the old state, this internal reference should be used. This allows React to optimize the performance and avoids the component from having state changes which become incoherent with expected behavior.

By adding a 5 second delay between the click and the render, this app demonstrates how a problem could arise under simple conditions. This same idea can also be played out in more complex examples, such as when updating one property on an object, for example.

Instructions

Here's a toy example using a simple counter. We're going to click both buttons the same number of times. Let's aim for three clicks of each button. Each click resets the delay timer, so you'll have five seconds before these clicks are manifested.

value = 0

Breakdown

When incrementing using a global value, we get a funny problem. Although we know the value should update +1, any async behavior (like the one simulated here by the delay function) will throw this off. Until the async behavior is resolved (here, our five second delay finally elapsing), the state will once again reference the original global state—which of course hasn't yet been updated.

Meanwhile with the local updater, the state setter creates an internal sense of state that it passes out. This means that even if we are cycling through async behavior, we can continue making reliable updates.

In summary, that means:

  • incrementWithGlobal closes over value at click time. 3 rapid clicks all see value as 0, so each queued call does setValue(0 + 1). Final result: 1.
  • incrementWithInternal passes React a callback that includes an internal state. React applies queued callbacks sequentially against the latest state, so it's effectively as though there were setValue(0 + 1)setValue(1 + 1)setValue(2 + 1). Final result: 3.
export default function App() {
  const [value, setValue] = useState(0);

  function incrementWithGlobal() {
    setValue(value + 1);
  }

  function incrementWithInternal() {
    setValue((prevValue) => prevValue + 1);
  }

  return (
    <AppWrapper>
      <button onClick={() => delay(incrementWithGlobal)}>Global +1</button>

      <button onClick={() => delay(incrementWithInternal)}>Internal +1</button>

      <b>value = {value}</b>
    </AppWrapper>
  );
}
Coded & written by James Mitofsky