React js 18 Render Batching and Timing

Render Batching and Timing

By default, each call to setState() causes React to start a new render pass, execute it synchronously, and return. However, React also applies a sort of optimization automatically, in the form of render batching. Render batching is when multiple calls to setState() result in a single render pass being queued and executed, usually on a slight delay.

The React docs mention that "state updates may be asynchronous". That's a reference to this render batching behavior. In particular, React automatically batches state updates that occur in React event handlers. Since React event handlers make up a very large portion of the code in a typical React app, this means that most of the state updates in a given app are actually batched.

React implements render batching for event handlers by wrapping them in an internal function known as unstable_batchedUpdates. React tracks all state updates that are queued while unstable_batchedUpdates is running, and then applies them in a single render pass afterwards. For event handlers, this works well because React already knows exactly what handlers need to be called for a given event.

Conceptually, you can picture what React's doing internally as something like this pseudocode:

--

// PSEUDOCODE Not real, but kinda-sorta gives the idea
function internalHandleEvent(e) {
  const userProvidedEventHandler = findEventHandler(e);
  
  let batchedUpdates = [];
  
  unstable_batchedUpdates( () => {
    // any updates queued inside of here will be pushed into batchedUpdates
    userProvidedEventHandler(e);
  });
  
  renderWithQueuedStateUpdates(batchedUpdates);
}
/*However, this means that any state updates queued outside of the actual immediate call stack will not be batched together.

Let's look at a specific example.*/

const [counter, setCounter] = useState(0);

const onClick = async () => {
  setCounter(0);
  setCounter(1);
  
  const data = await fetchSomeData();
  
  setCounter(2);
  setCounter(3);
}

--

This will execute three render passes. The first pass will batch together setCounter(0) and setCounter(1), because both of them are occurring during the original event handler call stack, and so they're both occurring inside the unstable_batchedUpdates() call.

However, the call to setCounter(2) is happening after an await. This means the original synchronous call stack is done, and the second half of the function is running much later in a totally separate event loop call stack. Because of that, React will execute an entire render pass synchronously as the last step inside the setCounter(2) call, finish the pass, and return from setCounter(2).

The same thing will then happen for setCounter(3), because it's also running outside the original event handler, and thus outside the batching.

There's some additional edge cases inside of the commit-phase lifecycle methods: componentDidMountcomponentDidUpdate, and useLayoutEffect. These largely exist to allow you to perform additional logic after a render, but before the browser has had a chance to paint. In particular, a common use case is:

  • Render a component the first time with some partial but incomplete data
  • In a commit-phase lifecycle, use refs to measure the real size of the actual DOM nodes in the page
  • Set some state in the component based on those measurements
  • Immediately re-render with the updated data

In this use case, we don't want the initial "partial" rendered UI to be visible to the user at all - we only want the "final" UI to show up. Browsers will recalculate the DOM structure as it's being modified, but they won't actually paint anything to the screen while a JS script is still executing and blocking the event loop. So, you can perform multiple DOM mutations, like div.innerHTML = "a"; div.innerHTML = b";, and the "a" will never appear.

Because of this, React will always run renders in commit-phase lifecycles synchronously. That way, if you do try to perform an update like that "partial->final" switch, only the "final" content will ever be visible on screen.

Finally, as far as I know, state updates in useEffect callbacks are queued up, and flushed at the end of the "Passive Effects" phase once all the useEffect callbacks have completed.

It's worth noting that the unstable_batchedUpdates API is exported publicly, but:

  • Per the name, it is labeled as "unstable" and is not an officially supported part of the React API
  • On the other hand, the React team has said that "it's the most stable of the 'unstable' APIs, and half the code at Facebook relies on that function"
  • Unlike the rest of the core React APIs, which are exported by the react package, unstable_batchedUpdates is a reconciler-specific API and is not part of the react package. Instead, it's actually exported by react-dom and react-native. That means that other reconcilers, like react-three-fiber or ink, will likely not export an unstable_batchedUpdates function.

For React-Redux v7, we started using unstable_batchedUpdates internally, which required some tricky build setup to work with both ReactDOM and React Native (effectively conditional imports depending on which package is available.)

In React's upcoming Concurrent Mode, React will always batch updates, all the time, everywhere.

Render Behavior Edge Cases

React will double-render components inside of a <StrictMode> tag in development. That means the number of times your rendering logic runs is not the same as the number of committed render passes, and you cannot rely on console.log() statements while rendering to count the number of renders that have occurred. Instead, either use the React DevTools Profiler to capture a trace and count the number of committed renders overall, or add logging inside of a useEffect hook or componentDidMount/Update lifecycle. That way the logs will only get printed when React has actually completed a render pass and committed it.

In normal situations, you should never queue a state update while in the actual rendering logic. In other words, it's fine to create a click callback that will call setSomeState() when the click happens, but you should not call setSomeState() as part of the actual rendering behavior.

However, there is one exception to this. Function components may call setSomeState() directly while rendering, as long as it's done conditionally and isn't going to execute every time this component renders. This acts as the function component equivalent of getDerivedStateFromProps in class components. If a function component queues a state update while rendering, React will immediately apply the state update and synchronously re-render that one component before moving onwards. If the component infinitely keeps queueing state updates and forcing React to re-render it, React will break the loop after a set number of retries and throw an error (currently 50 attempts). This technique can be used to immediately force an update to a state value based on a prop change, without requiring a re-render + a call to setSomeState() inside of a useEffect.