State Updates & Batching
Every call to a state setter schedules a re-render — but React doesn't re-render immediately after every single setState call. Instead, React batches multiple state updates that happen in the same event handler into a single re-render. This is one of the most important performance optimizations in React, and understanding it explains a lot of "surprising" behavior you'll encounter as your applications grow.
What Batching Means
When you call multiple state setters inside a single event handler, React doesn't re-render the component after each call. It queues all the updates and applies them together at the end of the event handler, triggering exactly one re-render. This is batching.
import { useState } from 'react'
function Counter() {
const [count, setCount] = useState(0)
const [name, setName] = useState('Alice')
function handleClick() {
setCount(count + 1) // queued — no re-render yet
setName('Bob') // queued — no re-render yet
// React re-renders ONCE here, with count=1 and name='Bob'
}
console.log('render') // only logs once per click, not twice
return (
<button onClick={handleClick}>
{name}: {count}
</button>
)
}Without batching, the two setState calls above would trigger two separate re-renders — first with count=1, name='Alice', then with count=1, name='Bob'. The user would see a flicker, and your app would do twice the work. Batching eliminates both problems.
React 17 vs React 18: Automatic Batching
React has always batched updates inside React event handlers (click handlers, submit handlers, etc.). What changed in React 18 is that batching now works everywhere — including inside setTimeout, Promise.then, async/await, and native DOM event listeners.
// React 17: two re-renders (batching only worked inside React events)
setTimeout(() => {
setCount(c => c + 1) // re-render 1
setFlag(f => !f) // re-render 2
}, 1000)
// React 18: one re-render (automatic batching everywhere)
setTimeout(() => {
setCount(c => c + 1) // queued
setFlag(f => !f) // queued
// React re-renders ONCE here
}, 1000)// React 18: async functions are also batched
async function handleSave() {
const data = await fetchUser() // await suspends, then resumes
setUser(data) // queued
setLoading(false) // queued
setError(null) // queued
// One single re-render for all three updates
}State Updates Are Snapshots
A critical detail: when React batches updates, each state setter captures the state at the time the event handler started. This is the "snapshot" behavior — state doesn't update mid-handler.
function Counter() {
const [count, setCount] = useState(0)
function handleTripleClick() {
setCount(count + 1) // count is still 0 here
setCount(count + 1) // count is STILL 0 here (snapshot!)
setCount(count + 1) // count is STILL 0 here (snapshot!)
// Result: count becomes 1, not 3 — all three updates used count=0
}
return <button onClick={handleTripleClick}>{count}</button>
}To increment three times correctly, use the updater function form — pass a function to the setter instead of a value. React passes the previous (latest queued) state to each updater:
function handleTripleClick() {
setCount(prev => prev + 1) // prev = 0 → 1
setCount(prev => prev + 1) // prev = 1 → 2
setCount(prev => prev + 1) // prev = 2 → 3
// Result: count becomes 3 ✓
}Proving Batching with a Render Counter
Here's a complete example that makes batching visible by counting how many times the component renders:
import { useState, useRef } from 'react'
function BatchingDemo() {
const [a, setA] = useState(0)
const [b, setB] = useState(0)
const [c, setC] = useState(0)
const renderCount = useRef(0)
renderCount.current++
function handleUpdate() {
setA(x => x + 1)
setB(x => x + 1)
setC(x => x + 1)
// Three state updates → ONE re-render
}
return (
<div>
<p>a={a}, b={b}, c={c}</p>
<p>Render count: {renderCount.current}</p>
<button onClick={handleUpdate}>Update all three</button>
</div>
)
}
// After clicking the button once:
// a=1, b=1, c=1, Render count: 2 (initial + 1 batched update)Opting Out with flushSync
Occasionally you need a state update to flush to the DOM immediately — before the rest of your code runs. React provides flushSync from react-dom for this. It forces React to re-render synchronously inside the callback before returning.
import { useState } from 'react'
import { flushSync } from 'react-dom'
function TodoList() {
const [todos, setTodos] = useState([])
const listRef = useRef(null)
function handleAddTodo() {
// We need the DOM to update BEFORE we scroll to the new item
flushSync(() => {
setTodos(prev => [...prev, 'New todo'])
})
// DOM is already updated here — safe to scroll
listRef.current.lastElementChild?.scrollIntoView()
}
return (
<ul ref={listRef}>
{todos.map((todo, i) => <li key={i}>{todo}</li>)}
</ul>
)
}How Batching Improves Performance
The performance win from batching compounds quickly. Consider a form with multiple fields where submitting might update five or six pieces of state (loading flag, field errors, server error, user data, etc.). Without batching that would trigger five or six full re-renders of your component tree. With batching it's exactly one.
Fewer re-renders means less work for React's reconciler
Fewer re-renders means fewer DOM mutations, which are expensive
Intermediate states (e.g.,
loading=truewith stale data still showing) never reach the screenChild components re-render once instead of once per state update in the parent
Summary
React batches all state updates in an event handler into a single re-render
React 18 extended batching to async code,
setTimeout, native events, and PromisesState is a snapshot — calling
setState(value)multiple times with the same base value doesn't stackUse the updater function form (
setState(prev => ...)) when new state depends on previous stateUse
flushSyncsparingly when you need a synchronous DOM update