Why your React State isn't updating (The Snapshot Trap)
Understand how React state snapshots work and why state values remain constant within a single render, with practical examples and explanations.
function App(){
const [counter,seCounter] =useState(1)
return (
<div>
<p>{counter}</p>
<button onClick={()=>{
setCounter(counter + 1);
setTimeout(() => {
console.log(counter);
}, 5000);
}}></button>
</div>
)
}
You might be shocked to know that this code will NOT print 2. 😮
Think about it: we increment the counter, then defer the console log by 5 seconds using the setTimeout API. By the time that log finally executes, the state has already been updated to 2 on your screen.
But the console shows a different reality: a “1” gets printed every single time. 🤯
It feels like a glitch, but it’s actually React working exactly as intended. In this article, we’re going behind the scenes to clarify exactly what’s happening by explaining the concept of a state snapshot.
So, without any further ado, let’s get started.
useState Returns a Snapshot—A copy of the original state
As you might know, state doesn’t live inside the function component, but it’s stored within the React package itself!.
And provided as a snapshot or, in other terms, as a copy of the original state via the useState hook.
In fact, useState is named a hook because it’s hooking into the external state stored in React itself.
Why is the code printing “1” instead of “2”?
Component re-renders can be triggered via state updates. Therefore, when a given component re-renders**,** a new snapshot mirroring the latest updated state value is given to it, and then based on that value, the whole JSX, including the attached event handlers, gets re-created again.
By the time the second render happens, the whole JSX, including its corresponding attached event handlers, will be re-created again with the new counter state value, which will be equal to 2. But the previous event handler that has fired will only have access to the previous state snapshot with its corresponding JSX code. Therefore, the code prints 1.
So, every render is associated with the following:
-
A state snapshot.
-
And JSX code including event handlers.
Similarly, if you try to re-click on the button again, the counter state will be incremented to 3.
Then the component will re-render displaying 3 on the UI. After 5 seconds have elapsed, 2 will be printed because, as we said previously, the event handler was attached to the previous render; hence, the callback function is referencing the old state snapshot.
In other words, we can briefly say that the state that is returned by the useState hook is constant during every render.
Even if the event handler that is attached to one of the returned JSX elements were executing asynchronously in the future when many potential renders may have already occurred.
Every render is allocated its own constant state snapshot that never changes before the next render.
All the derived JSX code, including event handlers, is tied to that specific render.
In more specific words, renderers are totally isolated.
Render 1 can never access state snapshots in render 2.
Conclusion
To sum up, you can think of a re-render as a component starting a brand new life with a brand new state snapshot, JSX code, and event handlers.
Now take a look at this code.
// counter = 1
setCounter(counter +1)
setCounter(counter +1)
// counter = ??
Imagine if we try to update the counter state using “setCounter,” the setter function, two times sequentially. What would be the value of the counter state after the second render?
Well, that’s what we are going to discover in the next article, where we introduce React State Batching.
Until then, happy coding!