Skip to main content

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 setState calls happen fast, but the render fires once.
  • Without batching, 3 setState calls = 3 renders. With it, 3 calls = 1 render.
  • React 17 batched only inside React event handlers. React 18 batches everywhere, including promises and timeouts.
  • flushSync is not a batching tool. It breaks batching and forces a synchronous render immediately.

Quick example

jsx
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.

jsx
// 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 setState calls in the same event handler: nothing to do, automatic in both React 17 and 18.
  • State updates inside Promise.then or setTimeout on React 18+: still automatic.
  • State updates inside Promise.then on React 17: wrap in unstable_batchedUpdates from react-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.

jsx
// 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.

jsx
flushSync(() => setCount(1)); // synchronous render here const rect = ref.current.getBoundingClientRect(); // safe to measure now

Mistake 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.

jsx
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: batch from react-redux wraps multiple dispatches to trigger one store re-render.
  • React Query: useMutation batches optimistic updates so forms do not flicker on submit.
  • Next.js: useSWR mutations 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

jsx
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)

jsx
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:

jsx
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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?