Please see a code underneath. The expected and actual results are given below. Please help to understand the reason for the differences.
Result expected :
In browser :
// 1/2/3 (each digit delayed by half of a second)
In console :
/*
null,1
1,2
2,3
*/
Result received:
In browser :
// 0/2/3
In console :
/*
null,0
0,2
2,3
*/
App.js
const { useEffect, useState } = React;
function App() {
const [counter, setCounter] = useState(null);
useEffect(() => {
let i = 0;
setTimeout(() => {
setCounter((c) => {
console.log(c, i);
return i;
});
i = i + 1;
setTimeout(() => {
setCounter((c) => {
console.log(c, i);
return i;
});
i = i + 1;
setTimeout(() => {
setCounter((c) => {
console.log(c, i);
return i;
});
i = i + 1;
}, 500);
}, 500);
}, 500);
}, []);
return <>{counter && counter}</>;
}
ReactDOM.createRoot(document.body).render(<App />);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.production.min.js"></script>
Update : 3rd Feb
a) The most important take away from your answer, @Nick, to me is the need of a pure function. This is a very essential criterion to be met.
b) A state setter may be called synchronously or asynchronously depends on how React run time will decide. This is perfectly fine. However, some code enclosed in an async function like setTimeout is run synchronously here. We know what may happen at run time. As soon as setTimeout is encountered, the JS runtime executes this. This function per se being a Web api executed by the Browser. It essentially delays the code execution for the given time. And once it has been timed out, Browser will push the code into the task queue of JS runtime. The event look will pick it from there and execute the code and it is now done. Now the point which is still not understandable to me is that how React can intercept this entire processes and shortens it to a synchronous call. Certainly this is not a question to you, something still i try to figure out.
c) There are times the definition of function purity may require review. Because the state updater function over here has a specific signature as (oldState)=>newState. There is no room for us to add an argument here. This essentially prevents us to pass a value from its enclosing context. To circumvent this situation, we may create new variables in the scope and ensure it is intentionally unchanging. The updater in the following code still keeps reference to a variable outside, still it can function as a pure one. This topic has been specifically discussed over here, The Rules of React. This point has just been added here for your reference and comments if any.
...
useEffect(() => {
let i = 0;
setTimeout(() => {
let newI = i;
setCounter((c) => {
console.log(c, newI);
return newI;
});
i = i + 1;
setTimeout(() => {
let newI = i;
setCounter((c) => {
console.log(c, newI);
return newI;
});
i = i + 1;
setTimeout(() => {
let newI = i;
setCounter((c) => {
console.log(c, newI);
return newI;
});
i = i + 1;
}, 500);
}, 500);
}, 500);
}, []);
return <>{counter && counter}</>;
}
...
The main reason for this behaviour is because React doesn't necessarily call your state updater function synchronously:
setCounter((c) => {
console.log(c, I);
return i;
});
Typically, the callback function above isn't called synchronously (ie: immediately when this line of code is reached), but rather, is put on a queue and then run upon the next render to compute the new state value for counter
(ie: it is run asynchronously, at a later point in time). However, there are times when React will attempt to run it synchronously (ie: immediately/eagerly) if it is safe for React to do so. This is mainly for a performance optimisation that React does which allows them to bail out of a rerender if the state hasn't changed. Typically, React will call it synchronously on the first state update, and then for future state updates call it asynchronously.
Below are the steps to break down what happens:
The first setCounter((c) => { ... })
is ran. As this is the first state update, React runs the callback synchronously, meaning the callback is ran immediately, and thus has access to the current value of i
, that being 0
. This is what logs null, 0
.
i
is updated to be 1
(i = 1
)
React rerenders your component. Since it eagerly evaluated the new state beforehand, it can set the new counter
state to that computed value (ie: 0
). And so you see 0
rendered.
The second setCounter((c) => { ... })
is met, but here, unlike the first state update, the callback, (c) => { ... }
is not run yet. Instead, it is put onto a queue, and is only run upon the next render when the value of counter
is computed. That means that even though i
is currently 1
, if it changes before this function is executed, then that's the value of i
that this function will see and have access to (spoiler: that's what happens)
i
is updated to be 2
(i = 2
), by this point, the queued callback from #4 hasn't ran yet, so this is the value that this value will log and see
React rerenders for the first time, this runs the queued callback from #4, and ends up logging and rendering the current value of i
, that being 2
The third setCounter((c) => { ... })
is met, again, this is queued to run asynchronously when the next rerender occurs.
i
is updated to 3
(i = 3
)
React rerenders and calls the callback queued from step #7, as i
is now 3
, that is what is logged and also rendered.
With all that being said, you shouldn't rely on the timing for when the callback is executed as it's not guaranteed. This behaviour is only observable in your examples because your state updater function isn't a pure function. In React, the state updater function must be pure. In your case, your state updater function isn't pure as it relies on "free variables", ie i
, which is outside of the function's scope and thus can change the output of the function.