ReactState Updates & Batching

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.

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

JSX
// 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)

JSX
// 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
}
Note
React 18's automatic batching is enabled by default when you use `createRoot`. If your app still uses the legacy `ReactDOM.render()` API, you won't get automatic batching outside event handlers. This is one more reason to migrate to `createRoot`.
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.

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

JSX
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 ✓
}
Tip
Always use the updater function form (`setState(prev => ...)`) whenever the new state depends on the previous state. This is safe regardless of batching and is especially important in concurrent mode.
Proving Batching with a Render Counter

Here's a complete example that makes batching visible by counting how many times the component renders:

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

JSX
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>
  )
}
Warning
Avoid `flushSync` unless you have a genuine need for it. It breaks batching, forces synchronous re-renders, and can hurt performance. The most common legitimate use case is scrolling to a newly added DOM element — but even then, `useLayoutEffect` or `useEffect` is often the better tool.
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=true with stale data still showing) never reach the screen

  • Child 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 Promises

  • State is a snapshot — calling setState(value) multiple times with the same base value doesn't stack

  • Use the updater function form (setState(prev => ...)) when new state depends on previous state

  • Use flushSync sparingly when you need a synchronous DOM update