What is batching in React?
Batching in React is grouping multiple state updates into a single re-render to skip unnecessary intermediate renders.
Theory
TL;DR
- Batching is like a chef who preps all ingredients before turning on the stove: multiple
setStatecalls happen fast, but the render fires once. - Without batching, 3
setStatecalls = 3 renders. With it, 3 calls = 1 render. - React 17 batched only inside React event handlers. React 18 batches everywhere, including promises and timeouts.
flushSyncis not a batching tool. It breaks batching and forces a synchronous render immediately.
Quick example
function Counter() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
const handleClick = () => {
setCount(c => c + 1); // queued
setFlag(f => !f); // queued
// React batches both → single re-render
};
return <button onClick={handleClick}>Count: {count}, Flag: {flag}</button>;
}Both updates are queued together. React commits them in one pass. The component renders once, not twice.
React 17 vs React 18
In React 17, batching only worked inside React-controlled events like onClick or onChange. Move those same setState calls into a setTimeout or a Promise.then and you got two separate renders. This was a quiet source of performance issues in async-heavy code.
React 18 changed this with a new scheduler model. Batching now applies in all contexts: event handlers, async callbacks, timeouts, intervals. The behavior is consistent regardless of where the update originates.
// React 17: 2 renders. React 18: 1 render.
function PromiseUpdater() {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
const trigger = () => {
Promise.resolve().then(() => {
setA(1); // React 17: renders here
setB(2); // React 17: renders again; React 18: batched
});
};
return <button onClick={trigger}>A: {a}, B: {b}</button>;
}When to use
- Multiple
setStatecalls in the same event handler: nothing to do, automatic in both React 17 and 18. - State updates inside
Promise.thenorsetTimeouton React 18+: still automatic. - State updates inside
Promise.thenon React 17: wrap inunstable_batchedUpdatesfromreact-dom. - Need to read the DOM synchronously right after a state change: use
flushSync, but only there.
How batching works internally
React collects state updates into a queue (part of react-reconciler) within a single update lane. Instead of calling commitRoot after every setState, it waits for the current scope to finish, then flushes the entire queue in one pass and renders once. In React 18, the scheduler uses a priority-based lanes model that separates urgent updates (user input) from deferred ones (transitions). That is why useTransition can defer low-priority work without affecting batching on the urgent path.
Common mistakes
Mistake 1: Assuming batching works everywhere in React 17.
// React 17: 2 renders
setTimeout(() => {
setA(1); // renders
setB(2); // renders again
}, 0);
// Fix for React 17
import { unstable_batchedUpdates } from 'react-dom';
setTimeout(() => {
unstable_batchedUpdates(() => {
setA(1);
setB(2);
});
}, 0);The fix works. But unstable_batchedUpdates carries that prefix for a reason. Upgrading to React 18 removes the problem entirely.
Mistake 2: Using flushSync to "enable" batching.
It does the opposite. flushSync breaks batching and forces a synchronous render on the spot. Use it only when you need a DOM measurement before the next frame runs.
flushSync(() => setCount(1)); // synchronous render here
const rect = ref.current.getBoundingClientRect(); // safe to measure nowMistake 3: Expecting startTransition updates to batch with urgent updates.
useTransition marks updates as low-priority. React may flush them in a separate pass from urgent updates. A batched urgent update and a deferred transition update can land in different render cycles. That is by design, not a bug.
const [isPending, startTransition] = useTransition();
startTransition(() => {
setFilter('done'); // low-priority, may render separately
});Mistake 4: Using StrictMode to test batching behavior.
StrictMode mounts components twice in development only. It does not simulate production batching. Use React DevTools Profiler to count actual render commits.
Real-world usage
- Redux Toolkit:
batchfromreact-reduxwraps multiple dispatches to trigger one store re-render. - React Query:
useMutationbatches optimistic updates so forms do not flicker on submit. - Next.js:
useSWRmutations auto-batch during SSR hydration in React 18. - Zustand:
batch(fn)handles multi-store updates in one render.
Follow-up questions
Q: How did batching change from React 17 to React 18?
A: React 17 batched only inside React event handlers. React 18 auto-batches in all contexts (promises, timeouts, intervals) using a new lanes-based scheduler.
Q: When does batching NOT happen?
A: In React 17, outside of React events. In any version, when you call flushSync it forces a synchronous render and exits the batch queue immediately.
Q: What is unstable_batchedUpdates and should you use it?
A: It is a legacy API that forces batching outside React events in React 17. Use it only if upgrading to React 18 is not an option. The "unstable" prefix is intentional.
Q: How does batching interact with useTransition?
A: Transitions are low-priority. React may flush them separately from urgent updates, so they do not always batch together with synchronous state changes. This is expected behavior, not a batching failure.
Q: In a production app, how would you debug a component that re-renders too many times despite batching?
A: Open React DevTools Profiler and record the interaction. Look for components with multiple commits in a single user action. Then check for flushSync calls, third-party libraries calling setState outside React events, or list items missing key props that force full subtree reconciliation. The why-did-you-render library adds per-component logs that show exactly what prop or state value changed between each render.
Examples
Basic: two updates, one render
function Form() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const reset = () => {
setName(''); // queued
setEmail(''); // queued
// both cleared in a single render
};
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} />
<input value={email} onChange={e => setEmail(e.target.value)} />
<button onClick={reset}>Reset</button>
</div>
);
}One click, two setState calls, one render. Without batching the form would flash through an intermediate state where name is empty but email still holds the old value.
Intermediate: filter with batched state (TodoMVC pattern)
function TodoList({ todos }) {
const [filter, setFilter] = useState('all');
const [visibleTodos, setVisibleTodos] = useState(todos);
const changeFilter = (newFilter) => {
setFilter(newFilter); // queued
setVisibleTodos( // queued
todos.filter(t =>
newFilter === 'all' || t.status === newFilter
)
);
// single render: filter badge and list update together
};
return (
<div>
<button onClick={() => changeFilter('done')}>Show Done</button>
<ul>{visibleTodos.map(t => <li key={t.id}>{t.text}</li>)}</ul>
</div>
);
}If these two updates rendered separately, you would see a frame where the filter badge says "done" but the list still shows all items. Batching prevents that torn state from ever reaching the screen.
Advanced: the async trap (React 17 vs 18)
I once debugged a dashboard where every data fetch triggered two visible loading flickers. The component was calling setState inside a Promise.then on React 17. Here is a simplified version:
function Dashboard() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const fetchData = async () => {
setLoading(true); // React 17: render #1
const result = await fetch('/api/stats').then(r => r.json());
// Inside Promise.then — React 17 does NOT batch these
setData(result); // React 17: render #2
setLoading(false); // React 17: render #3
// React 18: one render after the await
};
return (
<div>
{loading && <p>Loading...</p>}
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
<button onClick={fetchData}>Fetch</button>
</div>
);
}In React 17 this produces three separate renders, including a frame where data is set but loading is still true. That is the flicker. In React 18, the two post-await updates batch automatically. If you are on React 17 and cannot upgrade, wrap the post-await updates in unstable_batchedUpdates.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.