Reductio ad absurdum: state desync in React

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.
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:
incrementWithGlobalcloses over value at click time. 3 rapid clicks all seevalueas0, so each queued call does setValue(0 + 1). Final result: 1.incrementWithInternalpasses React a callback that includes an internal state. React applies queued callbacks sequentially against the latest state, so it's effectively as though there weresetValue(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>
);
}


