Command Palette

Search for a command to run...

Hectal
PHASE 4Beginner ~22 min· topic 5 of 8

Topic 4.5

Batching and Updater Functions

In one line

React collects all the state updates from one event into a queue and renders once at the end (batching); pass an updater function like setN(n => n + 1) when the next value depends on the previous one, so each update builds on the latest queued value.

Think of it like this

A waiter taking a table's order. The waiter doesn't run to the kitchen after each person speaks. They listen to everyone, write the whole order down, and only then go to the kitchen once. React does the same with state updates: it writes every update from one event into a queue and renders once when the event handler has finished. This is called batching.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Batching
React grouping several state updates into one re-render.
Automatic batching
Since React 18, updates are batched everywhere: event handlers, timers, promises.
Update queue
React's list of state updates waiting to be applied in the next render.
Updater function
A function you pass to a setter, like n => n + 1, that computes the new value from the previous one.
Pending state
The value after all earlier updates in the queue have been applied: what an updater receives.
Replace
Passing a plain value to a setter: it throws away the previous value and uses this one.
flushSync
A react-dom function that forces React to apply updates right now instead of batching them.

Step by step

01Three setters with a value

The snapshot says number is 0 in all three lines, so each line queues the same thing.

Counter.jsxwhole filejsx
// render where number is 0
setNumber(number + 1); // setNumber(0 + 1): replace with 1
setNumber(number + 1); // replace with 1
setNumber(number + 1); // replace with 1
// next render: number is 1

02Three setters with an updater

Each updater is queued. In the next render React runs them in order, passing each the result of the previous one.

Counter.jsxwhole filejsx
setNumber((n) => n + 1);
setNumber((n) => n + 1);
setNumber((n) => n + 1);
Three setters with an updaterdiagram
Rendering diagram…

03Work through a mixed queue

Go through the queue in order and write down what each update produces. Plain values ignore what came before.

1. replace with 5 gives 5. 2. n => n + 1 receives 5 and gives 6. 3. replace with 42 ignores the 6 and gives 42. The next render shows 42.

Quiz.jsxwhole filejsx
// number is 0
setNumber(number + 5);
setNumber((n) => n + 1);
setNumber(42);
// next render: 42

04One render for the whole event

However many setters run in one handler (or one timer callback), React renders once at the end. The screen never shows a half-applied update.

One render for the whole eventdiagram
Rendering diagram…

05Pick sensible updater names

Name the argument so a reader knows which state it is.

names.jsxwhole filejsx
setEnabled((e) => !e);
setLastName((ln) => ln.trim());
setFriendCount((fc) => fc * 2);
setCount((prevCount) => prevCount + 1);

Try it yourself

  1. 1

    Predict, then check

    In the quiz, add a Case C: setNumber((n) => n + 1); setNumber(number + 5);. From 0, predict the result before running. (5: the replacement comes last and uses the snapshot.)

  2. 2

    Count the renders

    In the first example, make the plain button call setPlain ten times. How many render: lines appear for one click? (Still one.)

  3. 3

    Make the updater impure

    Wrap the first example's render in <StrictMode> and add console.log("updater ran") inside an updater. Each updater runs twice in development, which is why updaters must only calculate.

Code & diagrams

setN(n + 1) three times versus the updater three times New tab

Each click causes exactly one render (batching), even with three setter calls. Values add 1; updaters add 3.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

render: plain = 0 | updater = 0
render: plain = 1 | updater = 0
render: plain = 1 | updater = 3
Automatic batching in timers and promises React 18+ New tab

Before React 18 (with the old `ReactDOM.render`), the three updates after `await` would have caused three renders. With `createRoot` they are batched into one.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

render: guest | idle | 0
render: Asha | saved | 1
Quiz: mixing values and updaters New tab

Case A: replace with 0 + 5, then 5 + 1 = 6. Case B starts from 6: replace with 11, then 12, then replace with 42. A plain value wipes out everything queued before it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

after case A: 6
after case B: 42

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

A +3 button that only adds 1

Write a score button whose handler calls setScore(score + 1) three times.

terminal
$ Preview
── what you'll see ──
Each click adds 1 instead of 3. No error, no warning.

Break #2

Rely on a render between two setters

Call setItems(newItems) and then, in the same handler, read items.length to update a counter: setCount(items.length).

terminal
$ Preview
── what you'll see ──
The counter is always one step behind the list.

Myth vs fact

Myth

Each setter call causes its own render.

Fact

Since React 18, all updates in one event (or one timer, promise or native callback) are batched into one render.

Myth

Updater functions are only for counters.

Fact

Any state calculated from its previous value benefits: toggling (setOpen((o) => !o)), appending to arrays, merging objects.

Myth

You should always use updaters and never plain values.

Fact

Plain values are fine when the new state doesn't depend on the old one (setName("Asha")) or when you update only once per event. Updaters matter when updates stack up.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Opt out of batching with flushSync(() => setX(1)) from react-dom: React renders and commits synchronously before flushSync returns. It is slow and rarely needed (measuring or scrolling to a newly added DOM node, Phase 8).

  • ▸

    Updates are processed by priority lanes: a click's update is urgent; updates inside startTransition are lower priority and can be interrupted (Topic 16.7). Updaters in a low-priority lane are replayed on top of urgent ones, so the final state is the same as if they'd run in order.

  • ▸

    In StrictMode, React calls updater functions twice (and keeps one result) to expose impure updaters, such as ones that push into an array they were given.

  • ▸

    Automatic batching only applies to roots created with createRoot. The old ReactDOM.render (removed in React 19) kept React 17's behaviour.

Remember this

  1. 1

    Batching means grouping several state updates into a single re-render. It makes apps faster (one render instead of five) and avoids showing a half-updated screen where one value changed but another hasn't yet. React only starts the render after all the code in your handler has run.

  2. 2

    Automatic batching (React 18+). Before React 18, only updates inside React event handlers were batched; updates inside setTimeout, promises or native listeners each caused their own render. With createRoot (React 18 and later), every update is batched, wherever it comes from. React never batches across separate intentional events: two clicks are two renders, so a form can't be submitted twice by one batch.

  3. 3

    The classic surprise: setN(n + 1); setN(n + 1); setN(n + 1); adds 1, not 3. Because of the snapshot (Topic 4.4), n is 0 in all three lines, so you queue "replace with 1" three times. The queue ends at 1.

  4. 4

    Updater functions. Instead of a value, pass a function: setN((n) => n + 1). React puts the function in the queue and, during the next render, runs each one in order, feeding each the result of the previous one: 0 → 1 → 2 → 3. The function receives the pending state (the latest value after all earlier updates in the queue), not the snapshot. An updater must be pure: only calculate and return the new value, no side effects, because StrictMode calls it twice to check.

  5. 5

    Mixing values and updaters. The queue is processed in order: a plain value means "replace with this", an updater means "compute from the previous". setN(n + 5); setN((x) => x + 1); with n = 0 gives 6. Adding setN(42) at the end gives 42, because a replacement throws away whatever came before.

  6. 6

    When to use an updater: whenever the new state is calculated from the old one and you might update more than once before the next render, or the callback may run with a stale snapshot (timers, intervals, async code). Many teams use updaters for every "based on previous" update for consistency; it's never wrong. Naming: call the argument after the first letters of the state (setEnabled((e) => !e), setLastName((ln) => ln.toUpperCase())) or repeat the full name (setCount((prevCount) => prevCount + 1)), so it's clear what it holds.

Explain it without notes

01

What is batching, and what changed in React 18?

02

Why does calling setN(n + 1) three times add only 1?

03

How does an updater function work, and what does it receive?

04

With n = 0, what is the result of setN(n + 5); setN((x) => x + 1); setN(42); and why?

Practice

01

A Likes component has a +10 button that should call a helper addOne() ten times in a loop. Write addOne so the final count really goes up by 10.

02

Starting from n = 3, what does setN((x) => x * 2); setN(n + 1); setN((x) => x - 1); produce? Show the queue.

Trade-offs

  • ↔

    Updaters are always correct for derived updates but slightly noisier to read; plain values are clearer for simple replacements.

  • ↔

    Batching saves renders but means the DOM isn't updated until your handler ends; flushSync trades performance for that immediacy.

Done when you can

  • I can explain batching and automatic batching in React 18+.

  • I can predict the result of several setter calls in one handler.

  • I can use an updater function and know what it receives.

  • I can work through a queue that mixes values and updaters.

  • I name updater arguments clearly.