Why does each state hook update cause a separate re-render in asynchronous code?

Viewed 459

There are two useState hooks used in a component. If we allow for the event loop to "reflow" before modifying the state, each hook causes a separate re-render.

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export default function App() {
  const [foo, setFoo] = useState("initial_foo");
  const [bar, setBar] = useState("initial_bar");

  const onAction = useCallback(async () => {
      await sleep(0); // this is the culprit
      setFoo("updated_foo");
      setBar("updated_bar");
  }, []);

  console.log(`rendering foo:${foo} bar:${bar}`);

  return (
    <>
      <button onClick={onAction}>run test</button>
      <div>{"foo: " + foo}</div>
      <div>{"bar: " + bar}</div>
    </>
  );
}

console output with await sleep(0):

rendering foo:initial_foo bar:initial_bar
rendering foo:updated_foo bar:initial_bar
rendering foo:updated_foo bar:updated_bar

If we remove await sleep(0);, everything works as expected: setFoo and setBar cause a single re-render.

console output without await sleep(0):

rendering foo:initial_foo bar:initial_bar
rendering foo:updated_foo bar:updated_bar

The demo (in the demo console output is duplicated, because it has React strict mode on)

  • Why is it happening?
  • Is it a bug or a feature?
  • How can we prevent intermediate re-renders of this kind?

Update:

Issue has been created in the React project repo

1 Answers

In promises (and timers for that matter) setState is synchronous. So, you have two rerenders (first for setFoo and then for setBar) instead of combined (batched) one.

Here's an example:

If you click on setState in Promise button you'll get rerender logged after each state, but if you click on setState in an eventHandler you'll get two setState logs first and then there's only one rerender. This behavior should change in React 17 where sate updates will only be asynchronous.

function App() {
  const [foo, setFoo] = React.useState(); 
  const [bar, setBar] = React.useState();
  function handlerPromise() {
    Promise.resolve().then(() => {
      console.log("setFoo");
      setFoo({});
      console.log("setBar");
      setBar({});
    }) 
  }
      function handler() {
      console.log("setFoo");
      setFoo({});
      console.log("setBar");
      setBar({});

  }
  console.log("rerender");
  return (
  <div>
  <button onClick={handlerPromise}>setState in Promise</button>
  <button onClick={handler}>setState in an eventHandler</button>
  </div>
  )
}



ReactDOM.render(<App/>, document.getElementById("root"))
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.1/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.1/umd/react-dom.development.js"></script>

<div id="root"></div>

Related