React 18:
Auto Batching is ineffective in handleClick!
export default function App() {
const [v, setV] = useState(0);
const handleClick = async () => {
console.log(0);
setV((v) => v + 1);
Promise.resolve().then(() => {
console.log(1);
setV((v) => v + 1);
console.log(2);
Promise.resolve().then(() => {
console.log(3);
});
});
};
const handleClick2 = async () => {
console.log(0);
Promise.resolve().then(() => {
console.log(1);
setV((v) => v + 1);
console.log(2);
Promise.resolve().then(() => {
console.log(3);
});
});
setV((v) => v + 1);
};
console.log("render");
return (
<div className="App">
<h1 onClick={handleClick}>Hello CodeSandbox</h1>
<h2>value: {v}</h2>
</div>
);
}
handleClick: render twice:
- 0
- render
- 1
- 2
- render
- 3
handleClick2: render once:
- 0
- 1
- 2
- render
- 3
Why React render twice in handleClick
2
Answers
More than React’s batched updates, this relates to how macrotasks and microtasks work.
setState
schedules a render asmacroTask
.Promise.resolve().then
schedules amicroTask
.Here is the microtask:
When you look at this: It seems all microTasks will run after a macroTask is run. And one cycle around the event loop will only run one macroTask.
So the order will matter, let’s see how:
In
handleClick
,setV
schedules amacroTask
and after thatPromise.resolve().then
schedules amicroTask
.And as mentioned here: Immediately after every macrotask, the engine executes all tasks from microtask queue, prior to running any other macrotasks or rendering or anything else.
In
handleClick2
, amicroTask
is scheduled first and then after that amacroTask
is scheduled bysetV
.So if you look at the logs in the playground, before the render (macroTask), microTask is run first and since the microTask has anothe r
setV
, thissetV
is batched with the previoussetV
and the final value of v is included in this one render.The two event handlers differ in the render pass due to its difference in the number of event loop ticks. It means the number of event loop ticks required to complete execution of the two handlers are different.
And the relevance of event loop tick is this : React 18 adds the feature of “automatic batching”. This new feature entirely depends upon the event loop ticks. It means React 18 will automatically batch the state setters queued up in the same event loop tick. The following two articles have more about this.
React 18 now does "automatic batching" of all updates queued in any single event loop tick
Render Batching and Timing
Now coming to your question:
The most obvious question now remains unanswered is this :
Why is there a difference in the event loop ticks between the two handlers?
Let us take the handler handleClick first.
It renders twice since there are two event loop ticks during its execution. Please see them below separately.
event loop tick #1 : Which loads the original event handler into the stack and queues up the first state setter.
event loop tick #2 : Inside the event handler there is an asynchronous code as Promise.resolve(…). Being this code asynchronous, it will immediately be queued up and the original handler will be freed from blocking. The code thus queued up would wait for the next event loop tick. As soon as the stack becomes empty when the original handler is finished, the next event loop tick will start and it will load the queued up callback function in the Promise.resolve(…) statement. This callback will queue up the second state setter. However, please do take note that this second state setter which has now been queued up is in the second event loop tick. Since the two state setters have been queued up in two different event loop ticks, there is no chance for React to perform the “Automatic Batching” and therefore there are two render passes in this event handler.
Let us take the other handler handleClick2.
It renders only once since the two state setters are queued in the same event loop tick. Let us see its details below.
event loop tick #1 : It loads the original event handler into the stack. However there is no state setter in the original handler before the asynchronous code inside it. Therefore there is no state setter queued up in this first event loop tick. It may seem a little strange since the second state setter in this handler has been coded outside the asynchronous code. Intuitively it may be thought as the second state setter is part of the original event handler as it is in the first event handler, however the code Promise.resolve(…) has a huge impact on its following codes. This has been separately explained below.
Whenever some asynchronous code like Promise.resolve(…) encounters, the following codes in the original handler would be queued up and the main program will be freed from blocking. As we know, non-blocking execution is the whole purpose of asynchronous codes. Therefore the callback inside the promise as well as the remaining codes in the original code – which is the second state setter, will wait for the next event loop tick to be picked up to load into the stack and thus get executed.
event loop tick #2 : As we discussed above, during the first event loop tick there is no state setter queued up, not even the second state setter which has been coded as part of the original event handler. Now the stack has become empty, and another event loop tick will start looking into the queued up functions and load it for execution. It will pick up the callback of the promise as well as the second state setter. As we know, the first state setter is inside the callback, the two state setters will be queued up to render in the same event loop tick. This is the reason that there is only one single render for the event handler handleClick2.
For further example, please see the code below. The await expression breaks the original event handler into two parts, and thus executed in two separate event loop ticks. For further explanation of this, please read the second link enclosed above.
Note : Like await expression in the code below, the Promise.resolve(…) in your code has the same impact on its containing function as we discussed above.