React 18 Bug: Updaters are NOT called twice for the first time when in Strict Mode
 
 
 
@Andrei0872
 
 

Andrei0872 commented on 4 May

Hi @ssmkhrj,

I'd like to share my perspective as to why those behaviours take place. In all cases, I've simply added a debugger; statement and then opened the Sources tab in the Dev Tools. Note that all the following links to CodeSandbox are forked versions with a debugger; added.


Let's see what happens in the first CodeSandbox app(useState + React 18):

const handleClick = () => {
    setCount((count) => {
      debugger;
      console.log("Clicked");
      return count + 1;
    });
  };

Looking at the call stack, we can see dispatchSetState being called:
image

A few things worth mentioning:

  • fiber is the current FiberNode associated with a React element(in this case, it's the App component)
  • fiber.alternate points to another FiberNode and, based on my understanding, it's also the gist of how React's Virtual DOM is implemented; I also wrote about this in a recent article
  • scheduleUpdateOnFiber will trigger an update on the FiberTree - in other words, the diffing algorithm will come into play; this means React will determine what changed and will commit everything at once on the real DOM

From the above image, notice that fiber.alternate is null and, based on my understanding, this indicates that React rendered only oncefiber.alternate is not null starting from the second render onwards. I went detail about these concepts in the linked article from above.

So, since fiber.alternate is null, React will try to compute the eager state. Notice how calling lastRenderedReducer will lead to calling the function from setCount(). So , here is why the console shows Clicked once. The reason this happens is that React is trying to avoid an unnecessary re-render if the new value is that same as the old value.

Next, when React eventually determines the new value to be displayed, it will check first if eagerState has been set before:

        if (update.hasEagerState) {
          // If this update is a state update (not a reducer) and was processed eagerly,
          // we can use the eagerly computed state
          newState = ((update.eagerState: any): S);
        } else {
          const action = update.action;
          newState = reducer(newState, action);
        }

and if that's the case, it won't invoke the function provided to setCount(). And this is what happens indeed when one clicks the button for the first time.

On subsequent times, because fiber.alternate is not longer nullupdate.hasEagerState will not be true, so the reducer function(i.e. setCount()'s argument) will be invoked. And since Strict Mode is used, the App function will be invoked twice, meaning that the reducer function will be called once more.

My take on this is that React works properly - the App function will be invoked twice as a result of using Strict Mode, but it will use the eagerly computed state if the button is clicked for the first time.


Let's now see the second example, which uses React 18 + useReducer:

const reducer = (count, incrementor) => {
  debugger;
  log("Reducer ran");
  return count + incrementor;
};

From the call stack

image

we can see that dispatchReducerAction is called instead of dispatchSetState. As shown there, dispatchReducerAction does not handle the eager state case, as opposed to dispatchSetState.

However, an interesting fact is that both hooks(useState and useReducer) use the same function(i.e. updateReducer) to determine the new state:

But, since dispatchReducerAction does not take into account the eager state part, only the second branch will be chosen every time:

        if (update.hasEagerState) {
          // If this update is a state update (not a reducer) and was processed eagerly,
          // we can use the eagerly computed state
          newState = ((update.eagerState: any): S);
        } else {
          const action = update.action;
          newState = reducer(newState, action);
        }

and this why the console logs twice on each click.


The useReducer example with React 17:

image

As you can see, there is no dispatchSetState nor dispatchReducerAction, but dispatchAction, which is used in

both useState and useReducer.

dispatchAction takes into account the possible eager state, so that's why the console logs only once when the button is clicked for the first time.


I'd say things work as expected since the App function is still called twice when using Strict Mode. The fact that the reducer function is not called multiple times is due to the eagerly computed state.

I don't know if the fact that useReducer does not handle the eager state should be considered a bug, but if it is, I'd gladly try to work on it :).