Skip to content
2

Automatic batching for fewer renders in React 18 #21

gaearon announced in Announcement
Automatic batching for fewer renders in React 18 #21
May 27, 2021 · 9 comments · 28 replies

@gaearon
gaearon May 27, 2021
Maintainer

Overview

React 18 adds out-of-the-box performance improvements by doing more batching by default, removing the need to manually batch updates in application or library code. This post will explain what batching is, how it previously worked, and what has changed.

Note: this is an in-depth feature that we don’t expect most users to need to think about. However, it may be relevant to educators and library developers.

What is batching?

Batching is when React groups multiple state updates into a single re-render for better performance.

For example, if you have two state updates inside of the same click event, React has always batched these into one re-render. If you run the following code, you’ll see that every time you click, React only performs a single render although you set the state twice:

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

  function handleClick() {
    setCount(c => c + 1); // Does not re-render yet
    setFlag(f => !f); // Does not re-render yet
    // React will only re-render once at the end (that's batching!)
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

This is great for performance because it avoids unnecessary re-renders. It also prevents your component from rendering “half-finished” states where only one state variable was updated, which may cause bugs. This might remind you of how a restaurant waiter doesn’t run to the kitchen when you choose the first dish, but waits for you to finish your order.

However, React wasn’t consistent about when it batches updates. For example, if you need to fetch data, and then update the state in the handleClick above, then React would not batch the updates, and perform two independent updates.

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 fetch callback):

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

  function handleClick() {
    fetchSomething().then(() => {
      // React 17 and earlier does NOT batch these because
      // they run *after* the event in a callback, not *during* it
      setCount(c => c + 1); // Causes a re-render
      setFlag(f => !f); // Causes a re-render
    });
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

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.

What is automatic batching?

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

This means that updates inside of timeouts, promises, native event handlers or any other event will batch the same way as updates inside of React events. We expect this to result in less work rendering, and therefore better performance in your applications:

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

  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!)
    });
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

Note: It is expected that you will upgrade to createRoot as part of adopting React 18. The old behavior with render only exists to make it easier to do production experiments with both versions.

React will batch updates automatically, no matter where the updates happen, so this:

function handleClick() {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
}

behaves the same as this:

setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
}, 1000);

behaves the same as this:

fetch(/*...*/).then(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
})

behaves the same as this:

elm.addEventListener('click', () => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
});

Note: React only batches updates when it’s generally safe to do. For example, React ensures that for each user-initiated event like a click or a keypress, the DOM is fully updated before the next event. This ensures, for example, that a form that disables on submit can’t be submitted twice.

What if I don’t want to batch?

Usually, batching is safe, but some code may depend on reading something from the DOM immediately after a state change. For those use cases, you can use ReactDOM.flushSync() to opt out of batching:

import { flushSync } from 'react-dom'; // Note: react-dom, not react

function handleClick() {
  flushSync(() => {
    setCounter(c => c + 1);
  });
  // React has updated the DOM by now
  flushSync(() => {
    setFlag(f => !f);
  });
  // React has updated the DOM by now
}

We don't expect this to be common.

Does this break anything for Hooks?

If you’re using Hooks, we expect automatic batching to "just work" in the vast majority of cases. (Tell us if it doesn't!)

Does this break anything for Classes?

Keep in mind that updates during React event handlers have always been batched, so for those updates there are no changes.

There is an edge cases in class components where this can be an issue.

Class components had an implementation quirk where it was possible to synchronously read state updates inside of events. This means you would be able to read this.state between the calls to setState:

handleClick = () => {
  setTimeout(() => {
    this.setState(({ count }) => ({ count: count + 1 }));

    // { count: 1, flag: false }
    console.log(this.state);

    this.setState(({ flag }) => ({ flag: !flag }));
  });
};

In React 18, this is no longer the case. Since all of the updates even in setTimeout are batched, React doesn’t render the result of the first setState synchronously—the render occurs during the next browser tick. So the render hasn’t happened yet:

handleClick = () => {
  setTimeout(() => {
    this.setState(({ count }) => ({ count: count + 1 }));

    // { count: 0, flag: false }
    console.log(this.state);

    this.setState(({ flag }) => ({ flag: !flag }));
  });
};

See sandbox.

If this is a blocker to upgrading to React 18, you can use ReactDOM.flushSync to force an update, but we recommend using this sparingly:

handleClick = () => {
  setTimeout(() => {
    ReactDOM.flushSync(() => {
      this.setState(({ count }) => ({ count: count + 1 }));
    });

    // { count: 1, flag: false }
    console.log(this.state);

    this.setState(({ flag }) => ({ flag: !flag }));
  });
};

See sandbox.

This issue doesn't affect function components with Hooks because setting state doesn't update the existing variable from useState:

function handleClick() {
  setTimeout(() => {
    console.log(count); // 0
    setCount(c => c + 1);
    setCount(c => c + 1);
    setCount(c => c + 1);
    console.log(count); // 0
  }, 1000)

While this behavior may have been surprising when you adopted Hooks, it paved the way for automated batching.

What about unstable_batchedUpdates?

Some React libraries use this undocumented API to force setState outside of event handlers to be batched:

import { unstable_batchedUpdates } from 'react-dom';

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

This API still exists in 18, but it isn't necessary anymore because batching happens automatically. We are not removing it in 18, although it might get removed in a future major version after popular libraries no longer depend on its existence.

Replies

9 comments
·
28 replies
1

This is a fantastically clear piece of writing, @gaearon. I love the sandbox examples. I was — admittedly — unaware of the different behaviors but feel like I could explain it after this description.

This post has a secondary effect of clarifying render vs createRoot as an "as part of adopting React 18". This is a very nice "upgrade carrot" ⬆️🥕. And you've given folks something to look for to identify success. Wins all 'round.

0 replies
1

TL;DR: Is this change intended?

-act(() => {
+await act(async () => {
  button.click()
})

// assert on effects scheduled by click()

In React 17 and below act(callback) ensured that every update and effect scheduled by callback() was actually flushed before returning. We had both a synchronous variant (act(() => {})) and an asynchronous variant (await act(async () => {})). Choosing which one to use was a matter of whether the callback returned a promise or not i.e. what "color" the callback has.

Now that passive effects from discrete events schedule a sync callback as opposed to a normal passive effect, they're no longer flushed in sync act. To put it more bluntly: act() doesn't seem to do anything here. We could just as well

button.click();
+await null;

// assert on effects scheduled by click()

I did some digging and I returned to the original behavior with

diff --git a/packages/react-dom/src/test-utils/ReactTestUtilsPublicAct.js b/packages/react-dom/src/test-utils/ReactTestUtilsPublicAct.js
index 91ce4c9c5f..59c7b10a77 100644
--- a/packages/react-dom/src/test-utils/ReactTestUtilsPublicAct.js
+++ b/packages/react-dom/src/test-utils/ReactTestUtilsPublicAct.js
@@ -193,8 +193,8 @@ export function act(callback: () => Thenable<mixed>): Thenable<void> {
         (isSchedulerMocked === false || previousIsSomeRendererActing === false)
       ) {
         // we're about to exit the act() scope,
-        // now's the time to flush effects
-        flushWork();
+        // now's the time to flush effects and sync callbacks
+        ReactDOM.flushSync(flushWork);
       }
       onDone();
     } catch (err) {

though it does feel heavy handed (I just want to flushSyncCallbacks technically).

full patch with updated tests
diff --git a/packages/react-dom/src/__tests__/ReactTestUtilsAct-test.js b/packages/react-dom/src/__tests__/ReactTestUtilsAct-test.js
index d2ea73e098..13d4c49ee8 100644
--- a/packages/react-dom/src/__tests__/ReactTestUtilsAct-test.js
+++ b/packages/react-dom/src/__tests__/ReactTestUtilsAct-test.js
@@ -150,7 +150,7 @@ function runActTests(label, render, unmount, rerender) {
         expect(Scheduler).toHaveYielded([100]);
       });
 
-      it('flushes effects on every call', async () => {
+      it('flushes effects on every call', () => {
         function App() {
           const [ctr, setCtr] = React.useState(0);
           React.useEffect(() => {
@@ -172,16 +172,16 @@ function runActTests(label, render, unmount, rerender) {
           button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
         }
 
-        await act(async () => {
+        act(() => {
           click();
           click();
           click();
         });
         // it consolidates the 3 updates, then fires the effect
         expect(Scheduler).toHaveYielded([3]);
-        await act(async () => click());
+        act(() => click());
         expect(Scheduler).toHaveYielded([4]);
-        await act(async () => click());
+        act(() => click());
         expect(Scheduler).toHaveYielded([5]);
         expect(button.innerHTML).toBe('5');
       });
diff --git a/packages/react-dom/src/test-utils/ReactTestUtilsPublicAct.js b/packages/react-dom/src/test-utils/ReactTestUtilsPublicAct.js
index 91ce4c9c5f..59c7b10a77 100644
--- a/packages/react-dom/src/test-utils/ReactTestUtilsPublicAct.js
+++ b/packages/react-dom/src/test-utils/ReactTestUtilsPublicAct.js
@@ -193,8 +193,8 @@ export function act(callback: () => Thenable<mixed>): Thenable<void> {
         (isSchedulerMocked === false || previousIsSomeRendererActing === false)
       ) {
         // we're about to exit the act() scope,
-        // now's the time to flush effects
-        flushWork();
+        // now's the time to flush effects and sync callbacks
+        ReactDOM.flushSync(flushWork);
       }
       onDone();
     } catch (err) {

I'm dreadding the confusion and churn this would add at which point it would be more reasonable to just tell people to always use await act(async () => {}). Otherwise we'd have to teach people when to flush the microtask queue vs when to use act. It's not so much about adding await to act since that's code-moddable but rather about collecting all the abstractions using act (e.g. fireEvent from @testing-library/react) and adding await to them as well.

At the same time you also added a unstable_concurrentAct to internal tests which does hint at just supporting await act(async () => {}).

2 replies
@acdlite

We should start a separate thread on act at some point. There's a lot of planned work that needs to be done before release. What's implemented today is far from how we want it to work in the future.

Quick summary: the current implementation of act is more convoluted than it should be because we originally though we needed to support act in production mode. But we also didn't want to add act logic to the production React DOM bundle. So we had to implement act using clever user space tricks that don't work exactly as we'd like.

Instead, we decided that act doesn't need to exist in production mode. Production mode is only used for testing in e2e environments, where act isn't useful. (In an e2e environment, you perform an interaction and wait for the DOM to update; no special framework integration required.) act is really designed for integration and unit tests, which in practice everyone runs in development only.

Because act will only exist in development mode, we can add additional logic to the development build of React DOM. What it will do is post React tasks on a special React queue, instead of posting them to the browser like in a real app. Then act can flush that queue directly — and synchronously — without any clever hacks.

The upshot is that the sync version of act will work as you describe. No need for await null to flush promise microtasks because we won't have scheduled a microtask at all. Though it's still useful for cases where you want to simulate an async event handler.

@acdlite

Created a separate thread here: #23

1

Awesome post @gaearon, the explanation with examples is super helpful. This is gonna be a big performance boost 🔥 .

This also means that with react 18 createRoot, we don't need to use ReactDOM.unstable_batchedUpdates anymore as the batching will be automatically done right?

Some suggestions

Does this break anything for Classes?

We might want to mention here that it's only for cases where automatic batching was not happening earlier just to remind the user?

This issue doesn't affect function components with Hooks because they always see a "snapshot" of state from a particular render. With Hooks, setting state does not update the local state variable, even if rendering itself happened synchronously

can we add a snippet here just to make it more clear?

function handleClick() {
    console.log("=== click ===");
    fetchSomething().then(() => {
      // React 18 WITHOUT createRoot does not batches these:
      setCount((c) => c + 1); // Causes a re-render
      // count = 0, flag false
      console.log(count, flag);
      setFlag((f) => !f); // Causes a re-render
      // count = 0, flag false
      console.log(count, flag);
    });
  }

"they always see a "snapshot" of state from a particular render"
As I understand, the local variable doesn't get updated when setting state(even if synchronous/without batching) hence it's not reflected in hooks.
But here it says "it sees from the snapshot from a particular render" (which is happening synchronously), which means theoretically snapshot would have gotten updated and should be able to see it but that's not what happens, I might be missing something silly here so could you please elaborate?

Also to confirm thoughsetState is async but for the cases where batching wasn't happening earlier, it means setState was getting synchronously as a result the value was retrievable after setting state in class components?

5 replies
@Andarist

But here it says "it sees from the snapshot from a particular render" (which is happening synchronously), which means theoretically snapshot would have gotten updated and should be able to see it but that's not what happens

The snapshot cannot be updated because even if a new render happens synchronously then that render will create a new closure (by calling the component’s function again). The snapshot is the closure state and if you call a function that has access to a particular closure/snapshot then it will only ever have access to that specific closure/snapshot - it can’t easily access closures of any renders that happened because of it.

With classes the situation is different because the snapshot was put on the this which was available for all renders/methods/etc of any given component’s instance - so updated state could “leak” between renders~

@ad1992

Thanks for the explanation @Andarist 💯

Suggestion: Though this is an implementation detail, can we add this in the post as well or maybe separate QnA which we can link here as well so it helps interested users to understand the reasoning behind this behavior?

@gaearon

gaearon Jun 3, 2021
Maintainer Author

This also means that with react 18 createRoot, we don't need to use ReactDOM.unstable_batchedUpdates anymore as the batching will be automatically done right?

Yes.

We might want to mention here that it's only for cases where automatic batching was not happening earlier just to remind the user?

Added.

As I understand, the local variable doesn't get updated when setting state(even if synchronous/without batching) hence it's not reflected in hooks.

Yes.

But here it says "it sees from the snapshot from a particular render" (which is happening synchronously), which means theoretically snapshot would have gotten updated and should be able to see it but that's not what happens, I might be missing something silly here so could you please elaborate?

Sorry, I may have made it sound more complex than it is. All I meant is that the state you get from useState during a particular render will not change. Event handler "captures" values from that render, so it doesn't matter how many times you set the state -- that render's event handler will forever "see" the values from that render. That's what I meant by a snapshot. I reworded that part.

Also to confirm though setState is async but for the cases where batching wasn't happening earlier, it means setState was getting synchronously as a result the value was retrievable after setting state in class components?

Previously, in class components, in places where batching was not happening, you could read the next state from this.state. This is because React mutated this.state to point to the updated state in the middle of your function. With Hooks, this can't happen because there's nothing for React to mutate — there is no this. This is why this doesn't happen for Hooks.

1

Related question: will unstable_batchedUpdates still exist in React 18 for compat purposes, even if it's a noop?

1 reply
@gaearon

gaearon May 29, 2021
Maintainer Author

Yes.

1

@bluebill1049
bluebill1049 Jun 3, 2021
Collaborator

This is definitely one of my favorite features for performance improvement.

Currently, we are batching formState update in react-hook-form such as below:

const [formState, setFormState] = React.useState({
  isDirty: false,
  isTouched: false,
  touchedFields: [],
  ...
});

setFormState({
  ...formState,
  isTouched: true,
})

This is kind of close to batch update, except it has a problem with useEffect dep.

const { formState: { touchedFields } } = useForm()

useEffect(() => { console.log(touchedFields) }, [touchedFields]) // not working
useEffect(() => { console.log(formState.touchedFields) }, [formState]) // working, but it fire other formState gets affected as well

Just to validate my assumption, with this new release. we can do the following:

any sync state update will result in a single batch, but it will break the batch and result in multiple state updates with any async update.

const [isDirty, setIsDirty] = React.useState(false);
const [dirtyFields, setDirtyFields] = React.useState([]);

const Test = async () => {
  setIsDirty(true);
  
  // anything execution except async action, which will break the batch?
  
  setDirtyFields([1,2,3,4]);
}
4 replies
@gaearon

gaearon Jun 3, 2021
Maintainer Author

Sounds right to me but it depends on what you mean by "async action". Do you want to make a sandbox to dive deeper?

@bluebill1049

Will do, thanks @gaearon.

1

How does this affect the timing of state updates queued in the commit and passive effect phases?

My understanding is that currently:

  • Updates queued in the commit phase (cDM/cDU/uLE) are executed synchronously
  • Updates queued in the passive effects phase (useEffect callbacks) are deferred to the end of the effects phase

Any changes there?

8 replies
@gaearon

gaearon Jun 3, 2021
Maintainer Author

Updates queued in the commit phase (componentDidMount/componentDidUpdate/useLayoutEffect) are executed synchronously

Yes.

Updates queued in the passive effects phase (useEffect callbacks) are deferred to the end of the effects phase

There's a small distinction here we have missed documenting. Effects originating from intentional user-initiated events (like clicks, key presses, etc — we call them discrete, here's the list) now run synchronously at the end of the current task (facebook/react#21150). This gives them consistent timing. In Legacy roots, they are unfortunately inconsistent and we can't fix that (facebook/react#20074 (comment)). So I would expect setting state from such effects to also be synchronous. But it's best to confirm with @rickhanlonii or @acdlite.

Or you can try it and tell us if it works. :-)

@markerikson

Gotcha. So, restating just to make sure I have this correct:

  • Updates queued in the passive effect phase still run at the end of the passive effect phase
  • The timing of the passive effect phase itself depends on the type of browser event that started the entire React event handling sequence -> state update, and in those "discrete" cases, passive effects run sync after all other behavior, instead of on a timeout.
@rickhanlonii

I think that's roughly correct, but for me, thinking in terms of "phases" breaks down when you start to consider concurrent rendering. Phases imply a linear progression, which was the case when everything was sync, but is not the case in the new model.

So I think of this more in terms of scheduling and priorities instead:

  • Updates queued in passive effects are scheduled to run at different priorities after all passive effects run.
  • The priority they're scheduled at depends on how the original update(s) were scheduled. Most of the time, updates inside of passive effects are flushed synchronously in a task with normal priority, at the latest. This is true even if the original update was inside a discrete event, flush sync, a timeout, promise tick, or transition.
  • The only time this is different (currently) is in offscreen trees. Those passive effects are scheduled in a task at Idle priority.
  • The "passive effect phase" ends when all effects have run. During that phase, updates may be scheduled, but they will run after the passive effect phase ends, according to their priority and how they were scheduled.
1

What happens if a given logical batch takes longer than 5ms and there is other work do be done? Does it get split or paused then continued?

1 reply
@gaearon

gaearon Jun 3, 2021
Maintainer Author

This seems unrelated to batching to me — it sounds like a general question about rendering. For most state updates, we will do all the work synchronously and never interrupt it. The way you can opt into interruptions is with startTransition:

startTransition(() => {
  setState(something)
})
1

This is because the state is being set "outside" the event handler (after it has already run):

It would be helpful to explain where the state is being set in this example and how it is "outside" the event handler. It can be confusing to the reader since it is happening inside the then method of the fetchSomething promise.

3 replies
@gaearon

gaearon Jun 9, 2021
Maintainer Author

Thanks! I removed “outside”. Is this rewording clearer?

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

@shrutikapoor08

This is perfect! A typo crept in here but otherwise makes a lot more sense now. Thank you!

but here we’re updating updating the state after the event has already been handled (in fetch callback):

@gaearon

gaearon Jun 13, 2021
Maintainer Author

Fixed!

1

This is very well-explained, @gaearon! You are an incredible teacher.

One small thing that could be a little clearer in this example:

  function handleClick() {
    fetchSomething().then(() => {
-     // React 17 and earlier does NOT batch these:
+     // React 17 and earlier does NOT batch these because they are now inside a callback:
      setCount(c => c + 1); // Causes a re-render
      setFlag(f => !f); // Causes a re-render
    });
  }

or something like that! For the people who are new to batching, it may be confusing that setCount and setFlag aren't batched if they are technically inside handleClick.

4 replies
@markerikson

markerikson Jun 11, 2021
Collaborator

Perhaps something like this?

// React 17 and earlier does not batch these, because this section runs outside of the original event handler

or "after the original event handler" or something like that.

@gaearon

gaearon Jun 11, 2021
Maintainer Author

Thank you @shaundai! (I can't take the full credit because we co-wrote this with @rickhanlonii and I mostly just did light editing!)

I did a small edit to address your point. Is it clearer?

@markerikson

LGTM 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Category
Announcement
Labels
None yet
Beta