React 18 Automatic Batching and Transitions
The Batching Gap That React 18 Closed
This was one of the most annoying inconsistencies in React for years. Before React 18, batching only worked inside React event handlers. Updates in setTimeout, promises, and native event listeners each triggered a separate render:
// React 17: Multiple renders from async code
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
// React 17 AND 18: Batched — one render
setCount(c => c + 1);
setFlag(f => !f);
}
function handleAsync() {
fetch('/api/data').then(() => {
// React 17: NOT batched — TWO renders!
// React 18: Batched — one render
setCount(c => c + 1);
setFlag(f => !f);
});
}
function handleTimeout() {
setTimeout(() => {
// React 17: NOT batched — TWO renders!
// React 18: Batched — one render
setCount(c => c + 1);
setFlag(f => !f);
}, 0);
}
}
React 18 finally made batching consistent everywhere. Every state update is batched, regardless of where it originates. No more surprises.
The Mental Model
Think of batching like a waiter taking orders. In React 17, the waiter was attentive during dinner (React event handlers) — collecting all orders before going to the kitchen. But at the bar (setTimeout, promises), the waiter ran to the kitchen after every single item: "One beer! runs to kitchen One appetizer! runs to kitchen"
React 18 made the waiter consistent everywhere: collect all pending orders, then make one trip to the kitchen. Whether you're at the dinner table, the bar, or yelling from the parking lot (native event listeners), the waiter batches your orders.
flushSync is yelling "I need this RIGHT NOW" — the waiter immediately processes just that order before continuing to collect from others.
Where Batching Now Works
React 18's automatic batching applies everywhere:
function Dashboard() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Inside Promise.then — NOW BATCHED
fetch('/api/dashboard')
.then(res => res.json())
.then(data => {
setData(data); // All three state updates
setLoading(false); // are batched into
setError(null); // a single render
})
.catch(err => {
setData(null);
setLoading(false);
setError(err.message);
});
}, []);
// Also batched in:
// - setTimeout / setInterval callbacks
// - Native DOM event listeners (addEventListener)
// - async/await functions
// - Web Worker message handlers
// - IndexedDB transaction callbacks
}
flushSync: The Escape Hatch
Sometimes you genuinely need a state update to commit to the DOM immediately -- before the next line of code runs. flushSync is your escape hatch for those rare cases:
import { flushSync } from 'react-dom';
function ChatInput() {
const [messages, setMessages] = useState([]);
const listRef = useRef(null);
function handleSend(text) {
// Without flushSync: scrollTo happens before the new message is in the DOM
// The list scrolls to the second-to-last message
flushSync(() => {
setMessages(prev => [...prev, { text }]);
});
// DOM is now updated — the new message is in the list
// This scroll now targets the correct bottom position
listRef.current.scrollTop = listRef.current.scrollHeight;
}
return (
<div>
<div ref={listRef}>{messages.map(m => <Message key={m.id} msg={m} />)}</div>
<input onKeyDown={e => e.key === 'Enter' && handleSend(e.target.value)} />
</div>
);
}
flushSync is heavy — it bypasses batching and forces a synchronous render cycle. Use it only when you need to read DOM state immediately after a state update.
flushSync only forces the update inside it to be synchronous. Other pending updates are still batched normally:
flushSync(() => {
setA(1); // Rendered and committed immediately
});
// DOM has A=1 at this point
setB(2); // This is batched normally with any subsequent updates
setC(3); // B and C render together in the next batchAlso, flushSync inside a pending transition will force the transition lane to process synchronously — defeating the purpose of the transition.
Batching + Transitions: The Full Picture
Now let's see how these two React 18 features play together, because this is where it all comes together:
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [filter, setFilter] = useState('all');
const [isPending, startTransition] = useTransition();
function handleSearch(e) {
const value = e.target.value;
// These are batched into ONE synchronous render:
setQuery(value);
setFilter('all');
// This is a SEPARATE render on TransitionLane:
startTransition(() => {
setResults(search(value));
});
}
// Total renders from one keystroke:
// 1. Sync render: query + filter update (batched)
// 2. Transition render: results update (concurrent, interruptible)
}
The sync updates batch together. The transition creates a separate render. Without batching, the sync updates would be two renders. Without transitions, the results update would block the UI.
Production Scenario: The Triple-Render Bug
A team migrates from React 17 to 18 and notices their loading state logic breaks:
// Worked in React 17 (unintentionally), breaks in React 18
function DataFetcher() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
async function fetchData() {
setLoading(true);
// React 17: This causes a re-render (loading=true shown)
// React 18: Batched with the updates below — loading=true never shown alone
try {
const res = await fetch('/api/data');
const json = await res.json();
setData(json);
setLoading(false);
// React 17: Two re-renders (data, then loading)
// React 18: One re-render (data + loading together)
} catch (err) {
setError(err);
setLoading(false);
}
}
}
In React 17, setLoading(true) caused an immediate render, showing the loading spinner. In React 18, it's batched with the subsequent updates after the await. The loading spinner might never appear because setLoading(true) and setLoading(false) are batched together.
The fix: React 18 batching works per microtask, not per function call. The await creates a microtask boundary, so setLoading(true) actually does render before the await.
// Actually correct in React 18 — await creates a yield point
async function fetchData() {
setLoading(true); // Render 1: loading = true (before await)
const res = await fetch('/api/data'); // Microtask boundary
setData(json); // Render 2: data + loading batched
setLoading(false);
}
Batching boundaries in React 18
React 18 batches updates within the same microtask. Here's when batching boundaries occur:
- Synchronous code: All
setStatecalls in the same synchronous block are batched. - After
await: The code afterawaitruns in a new microtask. Updates before and afterawaitare in different batches. - Promise
.then(): Each.then()callback is a new microtask boundary. setTimeoutcallback: The callback is a new macrotask, starting a fresh batch.flushSync: Forces an immediate batch boundary.
// Same batch (synchronous):
setA(1); setB(2); setC(3); // 1 render
// Different batches (await boundary):
setA(1);
await somePromise;
setB(2); setC(3); // 2 total renders
// Different batches (flushSync boundary):
setA(1);
flushSync(() => setB(2)); // Renders A+B immediately
setC(3); // Separate render for CCommon Mistakes
-
Wrong: Relying on intermediate renders between multiple setState calls for side effects Right: Use useEffect for side effects that depend on state changes
-
Wrong: Using flushSync to fix all batching-related issues Right: Restructure code to work with batching. Use flushSync only for DOM measurement after update
-
Wrong: Expecting state to update after setState within the same synchronous scope Right: State updates are always processed in the next render, not immediately
-
Wrong: Assuming batching happens across different tick boundaries Right: Batching is per synchronous block. await, setTimeout, and microtask boundaries create separate batches
Challenge
Predict the render count
Show Answer
4 renders total (including initial):
- Initial render:
render 0 0 0 - After click (sync batch):
render 1 1 0—setA(1)andsetB(1)are batched (same synchronous block beforeawait)
3. **After await**: `setC(1)` starts a new batch. Then `flushSync(() => setA(2))` forces an immediate render. But React processes the pending `setC(1)` with the flushSync together: `render 2 1 1`- After flushSync:
setB(2)andsetC(2)are batched into one final render:render 2 2 2
Key insight: the await creates a microtask boundary. Everything before await is one batch, everything after starts fresh. flushSync forces any pending updates to render immediately alongside the flushed update.
Quiz
Key Rules
- 1React 18 automatically batches all state updates everywhere — event handlers, setTimeout, promises, native events. Not just React event handlers.
- 2Batching boundaries are per synchronous execution block. await, setTimeout, and .then() callbacks start new batch contexts.
- 3flushSync forces immediate synchronous rendering. Use only when you need DOM state before the next line of code.
- 4Transitions create a separate render on a lower-priority lane. They are not batched with synchronous updates.
- 5State values don't change within the current closure. Even after flushSync, the local variables still hold old values. New values are in the next render.
- 6The batching behavior change from React 17 to 18 can break code that relied on intermediate renders — especially loading state patterns in async code.