ESC

Type to search the knowledge base.

Batching State Updates

How React 18+ batches setState in events, timeouts, and promises: when updates flush and why double setState still works.

intermediate3 min read
  • react
  • batching-state

Click a button that calls setCount(c => c + 1) twice. You still get +2, and React only re-renders once. That is batching: multiple state updates scheduled in the same turn get applied together before paint.

Before React 18, batching was mostly limited to React event handlers. Updates inside setTimeout, promises, or native listeners often flushed immediately — one render per setState. React 18’s automatic batching covers those paths in concurrent roots (createRoot).

Docs: Queueing a Series of State Updates, Automatic batching.

Mental model

State updates are requests, not synchronous mutations. When you call setX, React:

  1. Schedules an update on the fiber.
  2. In the same batch window, may receive more set* calls.
  3. On flush, applies all queued updates for that fiber, then re-renders once.

Functional updates (setC(c => c + 1)) chain correctly inside a batch because each updater receives the previous pending value, not a stale closed-over number.

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount((c) => c + 1);
    setCount((c) => c + 1);
    // One re-render; count becomes previous + 2
  }

  return <button onClick={handleClick}>{count}</button>;
}

Replace with setCount(count + 1) twice and you only get +1 — both reads see the same render’s count. That is not a batching bug; it is the classic stale-value trap.

Automatic batching outside events

function Search() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  async function run(q) {
    setLoading(true);
    setQuery(q);
    const data = await fetch(`/api/search?q=${encodeURIComponent(q)}`).then((r) => r.json());
    setResults(data.items);
    setLoading(false);
    // After await, updates in the same synchronous stretch still batch under React 18
  }

  return (
    <>
      <input onChange={(e) => run(e.target.value)} value={query} />
      {loading ? <p>Loading…</p> : <List items={results} />}
    </>
  );
}

You still get intermediate paints when awaits yield to the browser — batches are per task, not “the whole async function is one atomic UI transaction.”

Opting out with flushSync

Rarely you need the DOM to reflect state before the next line (measuring layout, integrating a library that reads the DOM immediately):

import { flushSync } from 'react-dom';

flushSync(() => {
  setOpen(true);
});
const h = panelRef.current.getBoundingClientRect().height;

flushSync forces a synchronous render/commit. Overuse destroys performance. Prefer layout effects or redesigning the flow. See flushSync rare cases.

Footguns

Mistake What happens
Expecting one batch across multiple macrotasks Each timeout/promise stretch flushes on its own
Using value form of setState twice in one handler Second write overwrites first; use functional updates
Wrapping everything in flushSync “to be safe” Extra commits, jank, harder concurrent reasoning
Assuming class setState merges for hooks Hooks replace the value you pass (objects need spreads)

Interview out-loud

“React batches multiple setState calls in the same event or discrete update window so we re-render once. Functional updaters chain correctly inside a batch. React 18 auto-batches more than 17, including timeouts and promises. flushSync opts out when you must read the DOM immediately after a state change.”

Further reading

Related guides