Skip to content

State and setState Batching

intermediate11 min read

setState Does Not Update State Immediately

This is the single biggest source of confusion for React developers. When you call setState, the state variable does not change on the next line. It cannot. The update is queued, and React processes the queue later.

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

  function handleClick() {
    setCount(count + 1);
    console.log(count); // Still 0, not 1
    setCount(count + 1);
    console.log(count); // Still 0, not 1
  }

  // After handleClick runs, count will be 1, not 2.
  // Both setCount calls see count = 0.
  return <button onClick={handleClick}>Count: {count}</button>;
}

Both setCount calls use the same stale value of count (0). The result is 0 + 1 = 1 both times. React deduplicates the identical update, and count becomes 1.

Mental Model

Think of setState as dropping a letter in a mailbox. You write your request and drop it in. The mail carrier (React) collects all letters at once and processes them together. You cannot open the mailbox and read the updated response immediately — you have to wait for delivery. The next render is when you receive the updated value.

The Functional Updater: Reading the Latest State

When the next state depends on the previous state, use the functional updater form:

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

  function handleClick() {
    setCount(prev => prev + 1); // prev = 0 → 1
    setCount(prev => prev + 1); // prev = 1 → 2
    setCount(prev => prev + 1); // prev = 2 → 3
  }

  // After handleClick: count = 3
  return <button onClick={handleClick}>Count: {count}</button>;
}

Each functional updater receives the most recent pending state, not the stale closure value. React applies them in order: 0 → 1 → 2 → 3.

How the state queue works internally

React maintains a queue of updates for each state hook. When you call setCount(value), it pushes { action: value } onto the queue. When you call setCount(fn), it pushes { action: fn }. During the next render, React processes the queue from front to back:

  1. Start with the current base state
  2. For each update in the queue:
    • If action is a function: newState = action(currentState)
    • If action is a value: newState = action
  3. The final result becomes the new state

This is why mixing value updates and functional updates can be confusing:

setCount(5);           // Queue: [{ action: 5 }]
setCount(prev => prev + 1); // Queue: [{ action: 5 }, { action: fn }]
// Result: 5 (from first update), then fn(5) = 6

Batching in React 18

Before React 18, batching only happened inside React event handlers. Timeouts, promises, and native event handlers triggered a re-render for each setState call.

React 18 introduced automatic batching — all state updates are batched, regardless of where they happen:

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

  async function handleSearch(text) {
    setQuery(text);
    setLoading(true);
    // In React 17: TWO re-renders here (one per setState)
    // In React 18: ONE re-render (batched)

    const data = await fetch(`/api/search?q=${text}`);
    const json = await data.json();

    setResults(json);
    setLoading(false);
    // In React 17: TWO more re-renders
    // In React 18: ONE re-render (batched)
  }

  return (/* ... */);
}

Where React 18 Batches

ContextReact 17React 18
Event handlersBatchedBatched
setTimeout/setIntervalNot batchedBatched
Promise .then/.catchNot batchedBatched
Native event listenersNot batchedBatched
fetch callbacksNot batchedBatched

Opting Out of Batching

In rare cases where you need a synchronous DOM update between state changes, use flushSync:

import { flushSync } from 'react-dom';

function handleClick() {
  flushSync(() => {
    setCount(c => c + 1);
  });
  // DOM is updated here

  flushSync(() => {
    setFlag(f => !f);
  });
  // DOM is updated again here
}
Common Trap

flushSync forces a synchronous re-render and DOM update. It is an escape hatch, not a tool for regular use. Using it inside effects or during rendering can cause layout thrashing. The React team warns that flushSync "significantly hurts performance" and should be used as a last resort — typically for integrating with non-React code that needs the DOM to be in sync.

The Object.is Comparison

React skips a re-render if the new state is identical to the current state, determined by Object.is():

const [user, setUser] = useState({ name: 'Alice', age: 30 });

// This DOES trigger a re-render (new object reference):
setUser({ name: 'Alice', age: 30 });

// This does NOT trigger a re-render (same reference):
setUser(user);

// This also triggers a re-render (mutation + same ref is BAD):
user.age = 31;
setUser(user); // Same reference! React skips the update.
// The mutation is invisible to React.
Common Trap

Mutating state objects and then calling setState with the same reference is the most dangerous state bug. React compares with Object.is, sees the same reference, and skips the re-render entirely. Your mutation is lost. Always create new objects: setUser({ ...user, age: 31 }) or setUser(prev => ({ ...prev, age: 31 })).

function CheckoutForm() {
  const [items, setItems] = useState([]);
  const [total, setTotal] = useState(0);
  const [discount, setDiscount] = useState(0);

  function applyDiscount(code) {
    // All three updates are batched into one render
    const discountPercent = validateCode(code);
    setDiscount(discountPercent);
    setTotal(prev => prev * (1 - discountPercent / 100));
    setItems(prev =>
      prev.map(item => ({
        ...item,
        discountedPrice: item.price * (1 - discountPercent / 100),
      }))
    );
    // One render with all three state values updated
  }
}
Info

When multiple pieces of state always update together, consider if they should be a single state object or managed with useReducer. Three setState calls that always fire together are a signal that the state should be consolidated.

Execution Trace
Event:
User clicks button
React event handler begins
Queue:
setCount(1), setFlag(true), setName('Bob')
Three updates pushed to queue
Batch:
React collects all updates before re-rendering
No intermediate renders
Process:
Apply updates: count=1, flag=true, name='Bob'
All updates applied in one pass
Render:
Component called once with all new values
Single re-render, not three
Commit:
DOM updated once
Single DOM commit with all changes
Common Mistakes
  • Wrong: Reading state immediately after setState Right: Use the functional updater to access latest state, or wait for the next render

  • Wrong: Calling setState multiple times with the same stale value: setCount(count + 1); setCount(count + 1) Right: Use functional updater: setCount(prev => prev + 1); setCount(prev => prev + 1)

  • Wrong: Mutating state and passing the same reference to setState Right: Always create new objects/arrays: setState({ ...obj, key: newValue })

  • Wrong: Using flushSync for regular state updates Right: Let React batch naturally. Use flushSync only for non-React DOM integration.

Quiz
What is the final value of count after handleClick?
Quiz
In React 18, how many re-renders does this setTimeout callback cause?
Quiz
What happens when you do: user.name = 'Bob'; setUser(user)?
Key Rules
  1. 1setState is asynchronous — the state variable does not change until the next render
  2. 2Use functional updaters (prev => next) when the new state depends on the previous state
  3. 3React 18 batches ALL state updates — event handlers, timeouts, promises, everything
  4. 4Object.is comparison determines if React re-renders — same reference means no re-render
  5. 5Never mutate state objects — always create new references with spread or structured clone

Challenge: Predict the Count

Batching Behavior Analysis

Show Answer

The answer is 3. Here is the queue processing:

  1. setCount(count + 1)setCount(0 + 1) → queues value 1
  2. setCount(count + 1)setCount(0 + 1) → queues value 1
3. `setCount(prev => prev + 1)` → queues function updater
4. `setCount(prev => prev + 1)` → queues function updater

React processes the queue starting from base state 0:

  • Update 1: value 1 → state becomes 1
  • Update 2: value 1 → state becomes 1 (same value)
  • Update 3: fn(1)1 + 1 → state becomes 2
  • Update 4: fn(2)2 + 1 → state becomes 3

The first two value updates both replace state with 1. The functional updaters then chain correctly: 1 → 2 → 3.