Does React batch state update functions when using hooks?
Asked Answered
A

7

118

For class components, this.setState calls batch if inside event handlers. But what happens if state is updated outside the event handler and using useState hook?

function Component() {
  const [a, setA] = useState('a');
  const [b, setB] = useState('b');

  function handleClick() {
    Promise.resolve().then(() => {
      setA('aa');
      setB('bb');
    });
  }

  return <button onClick={handleClick}>{a}-{b}</button>
}

Will it render aa - bb right away? Or it will be aa - b and then aa - bb?

Alberik answered 29/10, 2018 at 15:13 Comment(0)
T
158

TL;DR – if the state changes are triggered asynchronously (e.g. wrapped in a promise), they will not be batched; if they are triggered directly, they will be batched.

I've set up a sandbox to try this out: https://codesandbox.io/s/402pn5l989

import React, { Fragment, useState } from 'react';
import ReactDOM from 'react-dom';

import './styles.css';

function Component() {
  const [a, setA] = useState('a');
  const [b, setB] = useState('b');
  console.log('a', a);
  console.log('b', b);

  function handleClickWithPromise() {
    Promise.resolve().then(() => {
      setA('aa');
      setB('bb');
    });
  }

  function handleClickWithoutPromise() {
    setA('aa');
    setB('bb');
  }

  return (
    <Fragment>
    <button onClick={handleClickWithPromise}>
      {a}-{b} with promise
    </button>
    <button onClick={handleClickWithoutPromise}>
      {a}-{b} without promise
    </button>
      </Fragment>
  );
}

function App() {
  return <Component />;
}

const rootElement = document.getElementById('root');
ReactDOM.render(<App />, rootElement);

I've made two buttons, one triggers the state changes wrapped in a promise like in your code example, the other triggers the state changes directly.

If you look at the console, when you hit the button “with promise”, it will first show a aa and b b, then a aa and b bb.

So the answer is no, in this case, it will not render aa - bb right away, each state change triggers a new render, there is no batching.

However, when you click the button “without promise”, the console will show a aa and b bb right away.

So in this case, React does batch the state changes and does one render for both together.

Teapot answered 29/10, 2018 at 15:33 Comment(7)
btw, tried it out without Promise.resolve. setA and setB were batched as expected, similar to a class component (setState called within event handler).Alberik
Note from github.com/facebook/react/issues/10231#issuecomment-316644950 - This is implementation detail and may change in future versions.Sita
I think that the issue referenced by @Sita is not applicable to hooks, it's about class componentsWakefield
@Wakefield While the issue was created before Hooks, the comment itself is applicable to any state implementation, applications should not rely on current optimization details.Sita
Will they execute in order though?Eldred
the letters used in this example are terrifically confusing. also is b b correct? I think that's a typo.Lymph
@Lymph it's not a typo, when you handle the click with a promise, the state changes are not batches, thus at the first render, the value of b is still "b", hence the log statement “b b”Teapot
G
27

Currently in React v16 and earlier, only updates inside React event handlers such as click or onChange etc are batched by default. So just like classes state updates are batched in a similar way in hooks

There is an unstable API to force batching outside of event handlers for rare cases when you need it.

ReactDOM.unstable_batchedUpdates(() => { ... })

There is a plan to batch all state updates in future version on react probably v17 or above.

Now also if the state update calls from within event handler are in async functions or triggered due to async code they won't be batched where direct updates will be batched

Where without the sync code state updates are batched and async code updates aren't

function App() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  // async update from useEffect
  useEffect(() => {
    setTimeout(() => {
      setCount1(count => count + 1);
      setCount2(count => count + 2);
    }, 3000);
  }, []);

  const handleAsyncUpdate = async () => {
    await Promise.resolve("state updated");
    setCount1(count => count + 2);
    setCount2(count => count + 1);
  };

  const handleSyncUpdate = () => {
    setCount1(count => count + 2);
    setCount2(count => count + 1);
  };

  console.log("render", count1, count2);
  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>Start editing to see some magic happen!</h2>
      <button type="button" onClick={handleAsyncUpdate}>
        Click for async update
      </button>
      <button type="button" onClick={handleSyncUpdate}>
        Click for sync update
      </button>
    </div>
  );
}

https://codesandbox.io/s/739rqyyqmq

Goldman answered 4/4, 2019 at 18:53 Comment(3)
Note from github.com/facebook/react/issues/10231#issuecomment-316644950 - This is implementation detail and may change in future versions.Sita
state changes inside componentDidMount are batched as well.Bradlybradman
As far as I can tell, React 17.0.1 still does not batch updates outside of React event handlers.Cosmo
T
6

answer already given by @Patrick Hund .. Just Wanted to update here that with React 18 batch states update is possible for Promise, setTimeout as well by default.

Until React 18, we only batched updates during the React event handlers. Updates inside of promises, setTimeout, native event handlers, or any other event were not batched in React by default.

Check this out for detail explanation . https://github.com/reactwg/react-18/discussions/21

Tatiania answered 15/6, 2021 at 8:8 Comment(0)
G
5

If the event handler is react-based then it batches the updates. This is true for both setState or useState calls.

But it doesn't batch automatically in case the event is non-react based i.e. setTimeout, Promise calls. In short any event from Web APIs.

Gaultheria answered 18/9, 2019 at 15:14 Comment(0)
C
4

React 18 with createRoot, batches all updates automatically, no matter where they originate from.

Note that React 18 with legacy ReactDOM.render() keeps the old behavior. Use ReactDOM.createRoot() if you want to batch updates inside of timeouts, promises or any other event.

Here we update state twice inside of a timeout, but React renders only once:

import React, { useState } from "react";
import ReactDOM from "react-dom";

function App() {
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);

  function handleClick() {
    setTimeout(() => {
      setX((p) => p + 1);
      setY((p) => p + 1);
    }, 100);
  }

  console.log(`render x: ${x} y: ${y}`);

  return (
    <div className="App">
      <button onClick={handleClick}>Update with promise</button>

      <div>X: {x} </div>
      <div>Y: {y} </div>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
Cambridge answered 29/3, 2022 at 17:51 Comment(0)
T
1

This has been updated for React 18. They have introduced something called "Automatic Batching". In the earlier versions of React, batching was only done for the states triggered/updated by browser events, but with React 18 batching is done for all the states regardless of where they come from.

Consider the component:


    const App = () => {
      const [count, setCount] = useState(0);
      const [trigger, setTreigger] = useState(false);

      const handleClick = () => {
        setTimeout(() => {
          setCount(count => count++);
          setTrigger(trigger => !trigger);
        }, 100)    
      }
      
      console.log("Re-render", count, trigger);

      return (
        <div>
          <button onClick={handleClick}>
            Click Me!
          </button>
        </div>);
    }

Here, now with version 18, React performs batching for this case too. This shows that React is getting efficient with states regardless of where they come from.

You can verify it from the output of console.log in the above component.

Tokay answered 13/7, 2022 at 14:54 Comment(0)
G
0

Here's a Jest test that shows it only triggers the renders and useEffects once.

  it("two set states will trigger only one render with effect check", async () => {
    const renderFn = jest.fn();
    const effectFn = jest.fn();
    function MyComponent() {
      const [foo, setFoo] = useState("foo");
      const [bar, setBar] = useState("bar");

      const handleClick = useCallback(() => {
        setFoo("blah");
        setBar("blah");
      }, [])

      useEffect(()=> {
        noop(foo);
        noop(bar);
        effectFn();
      },[foo,bar]);
      renderFn();
      return <div data-testid="test" onClick={handleClick}>{foo}{bar}</div>;
    }

    const { unmount } = render(<MyComponent />);
    expect(screen.getByTestId("test").textContent).toEqual("foobar");
    expect(renderFn).toBeCalledTimes(1);
    expect(effectFn).toBeCalledTimes(1);
    fireEvent.click(screen.getByTestId("test"))
    expect(renderFn).toBeCalledTimes(2);
    expect(effectFn).toBeCalledTimes(2);
    expect(screen.getByTestId("test").textContent).toEqual("blahblah");
    unmount();
    expect(renderFn).toBeCalledTimes(2);
    expect(effectFn).toBeCalledTimes(2);
  })

If you're using with async it does not batch them and require another render to do the next set.

  it("two set states will trigger render with effect check with async handler per await", async () => {
    const renderFn = jest.fn();
    const effectFn = jest.fn();
    function MyComponent() {
      const [foo, setFoo] = useState("foo");
      const [bar, setBar] = useState("bar");

      const handleClick = useCallback(async () => {
        await new Promise<void>((resolve) => { setFoo("blah"); resolve() })
        await new Promise<void>((resolve) => { setBar("blah"); resolve() })
      }, [])

      useEffect(() => {
        noop(foo);
        noop(bar);
        effectFn();
      }, [foo, bar]);
      renderFn();
      return <div data-testid="test" onClick={handleClick}>{foo}{bar}</div>;
    }

    const { unmount } = render(<MyComponent />);
    expect(screen.getByTestId("test").textContent).toEqual("foobar");
    expect(renderFn).toBeCalledTimes(1);
    expect(effectFn).toBeCalledTimes(1);
    fireEvent.click(screen.getByTestId("test"))
    expect(renderFn).toBeCalledTimes(2);
    expect(effectFn).toBeCalledTimes(2);
    expect(screen.getByTestId("test").textContent).toEqual("blahbar");
    // second state update after await
    await act(() => Promise.resolve());
    expect(renderFn).toBeCalledTimes(3);
    expect(effectFn).toBeCalledTimes(3);
    expect(screen.getByTestId("test").textContent).toEqual("blahblah");
    unmount();
    expect(renderFn).toBeCalledTimes(3);
    expect(effectFn).toBeCalledTimes(3);
  })

Full source including other scenarios are in https://github.com/trajano/react-hooks/blob/master/src/__tests__/useStateBatchTest.tsx

Giustino answered 1/2, 2023 at 18:58 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.