javascriptreactjsreact-state

Different setState behaviour in React 17 vs React 18


I was bug fixing the FE code for my company's App which still uses React 17, and I notice different behaviour when setting the state between React 17 and React 18 (without strict mode)

Code:

import React, { useEffect, useState } from 'react';

export default function App(props) {
  const [state, setState] = useState(0);

  console.log('render', state);

  useEffect(() => {
    (async () => {
      for (let i = 0; i < 3; i++) {
        await new Promise((res) => {
          setTimeout(res, 500);
        })

        console.log('before set state');
        setState(val => val + 1);
        console.log('after set state');
      }
    })();
  }, []);

  return (
    <h1>{state}</h1>
  );
}

Console output:

React 17

render 0
before set state
render 1
after set state
before set state
render 2
after set state
before set state
render 3
after set state

React 18

render 0
before set state
after set state
render 1
before set state
after set state
render 2
before set state
after set state
render 3

It seems that in React 17, the setState synchronously triggers the re-render, which explains why we saw render {num} before the console output after set state. However, in React 18, we could see that the re-render happens after the console before set state after set state, which means the setState didn't immediately trigger the re-render.

Can someone explain what's happening here? Is this due to React 18's concurrent mode? Is there anything we should adjust when migrating from React 17 to React 18 due to this change of behaviour?

Codesandbox:


Solution

  • React 18 made changes to setState so that multiple can be batched into a single render. React 17 had batching too, but it could only work for synchronous code that happened in a react lifecycle event (eg, useEffect), or a dom event (eg, onClick). React 18 expanded that functionality, so now it works in your async function.

    As a result, the renders caused by your example no longer happen synchronously. Instead react waits to see if the remaining synchronous code will set any other states.

    https://react.dev/blog/2022/03/29/react-v18#new-feature-automatic-batching

    Is there anything we should adjust when migrating from React 17 to React 18 due to this change of behaviour?

    Typically no changes are needed; it just improves your performance by combining 2 renders into one (your specific example won't combine any). If you're using class components there are some edgecases which can break your code. See this post for details: https://github.com/reactwg/react-18/discussions/21#discussion-3385721

    If you want to force a render to happen synchronously you can, but this is very rarely needed and i do not recommend it:

    import { flushSync } from 'react-dom';
    // ...
    console.log('before set state');
    flushSync(() => {
      setState(val => val + 1);
    })
    console.log('after set state');