Batching in React

Shivam Jha

Shivam Jha / April 22, 2022

5 min read

One might think that React's useState hook is the simplest hook. Yet, there are some complexities.

What is batching ?

Batching is when multiple calls to setState are grouped into only one state update

function App() {
  const [count, setCount] = useState(0)
  const [flag, setFlag] = useState(false)

  useEffect(() => {
    // only output once per click
    console.log({ count, flag })
  }, [count, flag])

  const handleClick = () => {
    // Here, react will re-render only once
    // Hence, the state updates are `batched`
    setCount(c => c + 1)
    setFlag(f => !f)
  }

  return (
    <div className='App'>
      <button onClick={handleClick}>Click Me!</button>
      <h3 style={{ color: flag ? 'blue' : 'black' }}>Count: {count}</h3>
    </div>
  )
}

Why Batching ?

  • Great for performance, since avoids un-necessary re-renders.
  • Prevents any component from rendering "half-applied" state updates, which may lead to bugs.

Inconsistent Batching Behavior

However, React was (more about that later) not consistent about batching. For example, in an async function / promise based API, React would not batch the updates & independent updates would happen (performing two setState calls).

// little async function
const sleep = () => new Promise(resolve => setTimeout(resolve, 200))

export default function App() {
  const [flag, setFlag] = useState(true)
  const [count, setCount] = useState(0)

  const handleClick = async () => {
    // mimicing some async call
    // (ex, fecthing data from server, etc.)
    await sleep()

    setFlag(f => !f)
    setCount(c => c + 1)
  }

  useEffect(() => {
    // in this case, two console logs can be seen
    // since `setState` is called inside an asynchronous function
    // So,  React would not batch the updates, and perform two independent updates.
    console.log({ count, flag })

    // whenever `flag` or `count` changes, do somethig!
  }, [count, flag])

  return (
    <>
      <h2>React's Batching Behavior while inside async callbacks</h2>;
      <p>Count: {count}</p>
      <button
        onClick={handleClick}
        style={{ backgroundColor: flag ? 'orange' : 'blue', color: '#fff' }}
      >
        Click me!
      </button>
    </>
  )
}

Forced batching in async functions

To force setState to batch updates out of event handlers, unstable_batchedUpdates (an undocumented API) can be used:

import { unstable_batchedUpdates } from 'react-dom'

unstable_batchedUpdates(() => {
  setCount(c => c + 1)
  setFlag(f => !f)
})

This is because React used to only batch updates during a browser event (like click), but here we're updating the state after the event has already been handled (in aync function):

For demo, see React 17: forced batching outside of event handlers

Opt out of automatic batching

Some code may depend on reading something from the DOM immediately after a state change. For those use cases, ReactDOM.flushSync can be used to opt out of batching

Continuing with our previous example,

function App() {
  const [count, setCount] = useState(0)
  const [flag, setFlag] = useState(false)

  useEffect(() => {
    console.log({ count, flag })
  }, [count, flag])

  const handleClick = () => {
    // setCount((c) => c + 1);

    // Force this state update to be synchronous.
    ReactDOM.flushSync(() => setCount(c => c + 1))
    // By this point, DOM is updated.

    setFlag(f => !f)
  }

  return (
    <div className='App'>
      <button onClick={handleClick}>Click Me!</button>
      <h3 style={{ color: flag ? 'blue' : 'black' }}>Count: {count}</h3>
    </div>
  )
}

See ⚠️ ReactDOM.flushSync: Opt out of automatic batching in event handlers

  • However, ReactDOM.flushSync is not common & should be sparingly used.

flushSync flushes the entire tree and actually forces complete re-rendering for updates that happen inside of a call, so you should use it very sparingly. This way it doesn’t break the guarantee of internal consistency between props, state, and refs.

To read more about async behavior of this API & why setState is asynchronous, check out this awesome discussion RFClarification: why is setState asynchronous? #11527

Automatic Batching in React 18

React 18 includes some out-of-the-box improvements with ReactDOMClient.createRoot, which includes support for automatic batching

Starting in React 18, all updates will be automatically batched, no matter where they originate from.

So, call to setState inside of event handlers, async functions, timeouts or any function will batch automatically (same as inside react events) This will result in less rendering, and therefore better performance in react applications

function handleClick() {
  fetchSomething().then(() => {
    // React 18 and later DOES batch these:
    setCount(c => c + 1)
    setFlag(f => !f)
    // React will only re-render once at the end (that's batching!)
  })
}
  • Note that this automatic batching behavior will only work in React 18 with ReactDOM.createRoot
  • React 18 with legacy ReactDOM.render keeps the old behavior
  • To read more about Automatic batching in React 18, see Automatic batching for fewer renders in React 18 #21