In React, setState calls are typically batched. However, when setState calls occur within a native event handler that includes a microTask (such as a Promise), the batching behavior changes. Specifically, if a setState call is made first in the synchronous part of the handler, followed by a microTask operation with a state update, and then another setState call is made after the microTask, these setState calls are not batched as expected. The state update inside the Promise is not batched.
React version: 18.3.1
Steps To Reproduce
I have attached a code Sandbox below to reproduce the issue. You can run the code and observe the Count Value rendered, which does not align with the expected React behavior.
Link to code example:
https://stackblitz.com/edit/vitejs-vite-qhrv9o?file=src%2FApp.tsx,src%2FCounter.tsx&terminal=dev
The expected behavior
The state update call inside the Promise should be batched, resulting in the count value being 1 after the increment click.
...and then another
setState
call is made after the microTask
This is a misunderstanding. That call is not made after the microtask.
The point is that the setCount
calls you make outside the then
callback are both executed in the same synchronous execution flow. The then
callback will be only be executed when the call stack is empty. And that isn't the case as long as there is code to execute in the currently running click handler.
So this is the sequence of what happens in your code:
countRef.current
, which is 0setCount
the first time, with argument 0: the update is batched.then
method. The callback that is given to it is not yet executed. That callback is queued as a microtask.countRef.current
, which is still 0 because the earlier update is batched.setCount
the second time, again with argument 0: also this update is batched.The click handler returns and the call stack becomes empty. Now the JS engine finds the react microtask that will perform the batched updates asynchronously. This results in countRef.current
to be updated to 1.
No rendering cycle gets to execute yet, because there is our own promise-related microtask that now gets its turn to execute:
countRef.current
, which is now 1 because the earlier batched updates have already executed.setCount
the third time, now with argument 1: this update is batched.The then
callback returns and the call stack becomes empty. The JS engine finds another react microtask that will perform the newly batched update (only one this time). This results in countRef.current
to be updated to 2.
The call stack is empty again, and eventually a paint job is executed, rendering that 2. Note that you'll never see the 1 displayed, because this is the first paint job that runs after the click handler got executed.
The batching and rendering would not have behaved differently if you would have moved some code like this:
useLayoutEffect(() => {
countRef.current = count;
if (buttonRef.current) {
buttonRef.current.onclick = () => {
console.log('Click handler start');
setCount(countRef.current + 1);
console.log('After first setCount');
setCount(countRef.current + 1); // Moved this block here
console.log('After last setCount');
Promise.resolve().then(() => { // ...and only then this block
setCount(countRef.current + 1);
console.log('Inside Promise');
});
};
}
});
Whether you place the then
call at the start, the middle, or the end of the click handler body, it doesn't influence the order of executing those setCount
calls, nor the rendering.
You wrote:
The state update call inside the Promise should be batched, resulting in the count value being 1 after the increment click.
It is batched, but after react has already cleared the batch that was collected by the synchronous part of the click handler. Realise that both calls of setCount
that are made in that click handler were executed as part of the same synchronous flow. When the click handler returns, the JS engine gets to execute the react job that will process the batched updates. That happens before your own then
callback is dequeued and executed.