React 105 ⚛️ Automatic Batching Mechanics
Batching is the mechanism by which React groups multiple state updates into a single re-render. Before React 18, batching was limited to updates inside React event handlers. An update inside a setTimeout, a promise callback, or a native event listener triggered a separate render for each update. React 18 extended batching to all update sources, including promises, timeouts, native event handlers, and any other asynchronous callback . The extension is called automatic batching, and it is enabled by default when the application is rendered with createRoot.
Key point: Batching is not about the number of updates; it is about the number of renders. Five setState calls inside a single event handler produce one render, not five. The same five calls inside a setTimeout also produce one render in React 18, but they produced five renders in React 17.
Why automatic batching exists
The render cost problem. Every render is work. React calls component functions, reconciles the element tree, and commits DOM changes. If a handler updates three state variables, three renders would mean three reconciliations and three commits for a single user interaction. Batching reduces the work to one render, one reconciliation, and one commit. The performance difference is small for a simple component but significant for a large tree with expensive renders.
The consistency problem. Multiple updates that belong to the same logical action should be reflected together. A form submission that sets a loading flag, clears the form, and stores the result should produce a UI that shows all three changes at once. Without batching, the user might see an intermediate state—the form cleared but the loading flag not yet set—if the browser paints between renders. Batching ensures the UI transitions directly from the old state to the new state.
The React 17 limitation. In React 17, batching was applied only inside React event handlers because React controlled the execution context. An update inside a setTimeout happened outside that context, so React processed it immediately. React 18 removed this restriction by decoupling batching from the event system. The update queue is now managed independently of how the update was triggered .
a. How Batching Works Internally
When a state update is scheduled, React adds it to a queue associated with the Fiber that owns the state. It also marks the root with a lane representing the update’s priority. If the root is not already scheduled for that lane, React schedules a render. When the render begins, React processes all updates in the queue for the lanes being rendered, computes the new state, and commits once.
The scheduler processes work in a microtask. When the first setState is called, React schedules a microtask to flush the update queue. Before that microtask runs, any additional setState calls in the same synchronous execution add their updates to the same queue. When the microtask runs, all updates are processed together, and a single render is produced .
// All three updates are batched into one render
function handleClick() {
setCount(c => c + 1); // queued
setFlag(f => !f); // queued
setName('Alice'); // queued
// React schedules one render after the handler completes
}
The key is that the updates are queued, not applied immediately. The render happens after the current execution context finishes, which means all synchronous updates in that context are collected before the render begins.
b. Automatic Batching Across Sources
React 18 applies batching to updates from any source, not just React event handlers. The following sources all produce a single render for multiple synchronous updates.
// Timeout: batched in React 18
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// One render
}, 1000);
// Promise: batched in React 18
fetch('/api').then(() => {
setCount(c => c + 1);
setFlag(f => !f);
// One render
});
// Native event listener: batched in React 18
document.getElementById('btn').addEventListener('click', () => {
setCount(c => c + 1);
setFlag(f => !f);
// One render
});
In React 17, each of these produced two renders. In React 18, each produces one. The behavior is consistent across sources, which means developers do not need to remember which contexts are batched and which are not.
c. Opting Out and the flushSync Escape Hatch
Batching is the default, but there are cases where an update must be applied immediately. flushSync forces React to process the update and commit synchronously before returning. It is used when the result of the update must be read from the DOM immediately after the call.
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCount(c => c + 1);
});
// DOM is updated here
console.log(document.getElementById('count').textContent);
flushSync(() => {
setFlag(f => !f);
});
// DOM is updated again
}
flushSync breaks batching by design. Each call produces its own render and commit. It is expensive and should be used sparingly. The React documentation describes it as an escape hatch for cases where the DOM must be read after an update, such as scrolling to a new element or measuring a layout after a state change.
The other way to break batching is to await between updates. React does not batch across await boundaries because the execution context is suspended and resumed in a different microtask.
async function handleClick() {
setCount(c => c + 1); // Batched with the next update
await fetch('/api'); // Execution context suspended
setFlag(f => !f); // Separate render
}
The setCount and setFlag calls are in different microtasks, so they produce separate renders. This is not a bug; it is a consequence of how await works. The same happens with any other operation that yields to the event loop.
Complete Example Session
// ============================================
// PART 1: BATCHING IN EVENT HANDLER
// ============================================
function Component() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
// All three updates batched into one render
setCount(c => c + 1);
setFlag(f => !f);
setName('Alice');
}
return <button onClick={handleClick}>Click</button>;
}
// ============================================
// PART 2: BATCHING IN TIMEOUT (REACT 18)
// ============================================
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// One render in React 18
}, 1000);
// ============================================
// PART 3: BATCHING IN PROMISE (REACT 18)
// ============================================
fetch('/api').then(() => {
setCount(c => c + 1);
setFlag(f => !f);
// One render in React 18
});
// ============================================
// PART 4: BATCHING IN NATIVE EVENT (REACT 18)
// ============================================
document.getElementById('btn').addEventListener('click', () => {
setCount(c => c + 1);
setFlag(f => !f);
// One render in React 18
});
// ============================================
// PART 5: FLUSHSYNC — BREAK BATCHING
// ============================================
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCount(c => c + 1);
});
// DOM is updated here
flushSync(() => {
setFlag(f => !f);
});
// DOM is updated again
}
// ============================================
// PART 6: AWAIT BREAKS BATCHING
// ============================================
async function handleClick() {
setCount(c => c + 1);
await fetch('/api'); // Execution context suspended
setFlag(f => !f); // Separate render
}
// ============================================
// PART 7: BATCHING WITH UPDATER FUNCTIONS
// ============================================
function handleClick() {
// Updater functions queue relative to previous state
setCount(c => c + 1); // c becomes 1
setCount(c => c + 1); // c becomes 2
setCount(c => c + 1); // c becomes 3
// One render, count is 3
}
// ============================================
// PART 8: BATCHING WITH DIRECT VALUES
// ============================================
function handleClick() {
// Direct values overwrite each other
setCount(1);
setCount(2);
setCount(3);
// One render, count is 3
}
// ============================================
// PART 9: BATCHING IN CLASS COMPONENTS
// ============================================
class Component extends React.Component {
handleClick = () => {
this.setState({ count: 1 });
this.setState({ flag: true });
// One render in React 18 (batched by default)
};
}
// ============================================
// PART 10: MIGRATION FROM REACT 17
// ============================================
// React 17: updates in setTimeout were not batched
// React 18: all updates are batched
// Use createRoot instead of ReactDOM.render
// If a component depends on unbatched behavior,
// use flushSync to preserve it
The ten parts trace the batching behavior across sources and the mechanisms that break it. Part 7 shows the updater function form, which queues relative to previous state. Part 8 shows the direct value form, which overwrites. Part 5 and Part 6 show the two ways to break batching: flushSync and await.
Quick Reference
Batching Behavior by Source
| Source | React 17 | React 18 |
|---|---|---|
| React event handler | Batched | Batched |
setTimeout | Not batched | Batched |
| Promise callback | Not batched | Batched |
| Native event listener | Not batched | Batched |
await between updates | N/A | Not batched |
Updater vs. Direct Value
| Form | Behavior |
|---|---|
setCount(c => c + 1) | Queues relative to previous state |
setCount(1) | Overwrites previous value |
Breaking Batching
| Mechanism | Effect |
|---|---|
flushSync | Forces synchronous render and commit |
await between updates | Separate microtasks, separate renders |
Best Practices
✅ Do This:
// Use updater functions for dependent updates
setCount(c => c + 1);
setCount(c => c + 1); // ✅
// Use flushSync only when the DOM must be read after an update
flushSync(() => { setVisible(true); });
scrollTo(ref.current); // ✅
❌ Don’t Do This:
// Assume batching happens across await boundaries
setCount(1);
await fetch();
setFlag(true); // Separate render // ❌
// Use flushSync for every update
// It defeats batching and hurts performance // ❌
// Use direct values when the update depends on previous state
setCount(count + 1); // May be stale // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Update appears to be lost | Direct value overwrites previous | Use updater function |
Multiple renders after await | Execution context suspended | Accept or use flushSync |
DOM not updated after setState | Batching defers the render | flushSync or useEffect |
| Migration from React 17 | Renders decreased unexpectedly | Update tests; use flushSync if needed |
Real-World Examples
1. Event Handler Batching
setCount(c => c + 1);
setFlag(true); // One render
2. Timeout Batching
setTimeout(() => { setCount(1); setFlag(true); }, 1000); // One render
3. Promise Batching
fetch('/api').then(() => { setCount(1); setFlag(true); }); // One render
4. flushSync
flushSync(() => { setCount(1); });
5. Await Breaks Batching
setCount(1);
await fetch();
setFlag(true); // Separate render
6. Updater Function
setCount(c => c + 1);
setCount(c => c + 1);
7. Direct Value Overwrites
setCount(1);
setCount(2); // count is 2
8. Class Component Batching
this.setState({ a: 1 });
this.setState({ b: 2 }); // One render
9. Migration to createRoot
const root = createRoot(document.getElementById('root'));
root.render(<App />);
10. Batching and useTransition
startTransition(() => { setCount(1); setFlag(true); }); // One render
Visual
Batching in Event Handlers
┌──────────────────────────────────────────────────────────────┐
│ EVENT HANDLER │
│ │
│ ┌─────────────────┐ │
│ │ setCount(1) │────► Queue update │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ setFlag(true) │────► Queue update │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ setData('x') │────► Queue update │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Handler ends │────► React schedules one render │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ One render │ All three updates applied │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ One commit │ DOM updated once │
│ └─────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
React 17 vs. React 18
┌──────────────────────────────────────────────────────────────┐
│ REACT 17 REACT 18 │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ setTimeout │ │ setTimeout │ │
│ │ setCount(1) │ │ setCount(1) │ │
│ │ setFlag(true) │ │ setFlag(true) │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Render 1 │ │ Render 1 │ │
│ │ Render 2 │ │ (batched) │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ Two renders. One render. │
│ │
│ React 18 batches all update sources by default. │
│ │
└──────────────────────────────────────────────────────────────┘
Breaking Batching with flushSync
┌──────────────────────────────────────────────────────────────┐
│ flushSync │
│ │
│ ┌─────────────────┐ │
│ │ flushSync(() => │ │
│ │ setCount(1) │ │
│ │ ) │ │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Render │ Immediate │
│ │ Commit │ DOM updated │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Read DOM │ Can read updated value │
│ └─────────────────┘ │
│ │
│ flushSync forces the render and commit to complete │
│ before returning. It breaks batching by design. │
│ │
└──────────────────────────────────────────────────────────────┘
Await Breaks Batching
┌──────────────────────────────────────────────────────────────┐
│ ASYNC HANDLER │
│ │
│ ┌─────────────────┐ │
│ │ setCount(1) │────► Queue update │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ await fetch() │────► Execution suspended │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Microtask flush │────► Render with count=1 │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Resume │ │
│ │ setFlag(true) │────► Queue update │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Another render │────► Render with flag=true │
│ └─────────────────┘ │
│ │
│ Two renders. The await boundary separates the updates. │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Batching | Multiple updates → one render |
| React 17 scope | React event handlers only |
| React 18 scope | All update sources |
| Enabled by | createRoot |
Break with flushSync | Immediate render and commit |
Break with await | Separate microtasks, separate renders |
| Updater function | setCount(c => c + 1) queues relative to previous |
| Direct value | setCount(1) overwrites previous |
Key takeaways:
- Automatic batching is enabled by
createRoot. Applications that still useReactDOM.renderdo not get React 18’s batching behavior. The migration tocreateRootis the prerequisite for the new batching semantics . - Batching applies to all update sources. Timeouts, promises, native event listeners, and any other asynchronous callback are batched in React 18. The behavior is consistent, which means developers do not need to remember which contexts are batched .
flushSyncbreaks batching by design. It forces React to process the update and commit synchronously. It is an escape hatch for cases where the DOM must be read immediately after the update. It should be used sparingly because it defeats the performance benefit of batching.awaitbreaks batching. The execution context is suspended at theawaitand resumed in a different microtask. Updates on either side of theawaitare in different batches and produce separate renders. This is not a bug; it is a consequence of how the event loop works .- Updater functions queue relative to previous state.
setCount(c => c + 1)called three times producescount + 3, notcount + 1. The direct value form overwrites, sosetCount(1); setCount(2); setCount(3)produces3.
Remember: Batching is a scheduling optimization, not a correctness requirement. The state after a batch of updates is the same as the state after processing them one by one. The difference is the number of renders and the number of commits. Batching reduces the work by collecting updates that belong to the same logical action and applying them in a single pass. React 18 extends this behavior to all update sources, which makes the scheduling model consistent but changes the render counts that some applications may have depended on. The migration from React 17 to React 18 can surface these assumptions. flushSync is the escape hatch for the rare cases where the old behavior is required, and await is the natural boundary where batching does not apply.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!