reactjspromiseevent-handlingreact-state

SetState Calls not being batched inside Promise calls in React 18.3.1


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.


Solution

  • ...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:

    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:

    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.