State and setState Batching
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.
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:
- Start with the current base state
- For each update in the queue:
- If action is a function:
newState = action(currentState) - If action is a value:
newState = action
- If action is a function:
- 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) = 6Batching 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
| Context | React 17 | React 18 |
|---|---|---|
| Event handlers | Batched | Batched |
| setTimeout/setInterval | Not batched | Batched |
| Promise .then/.catch | Not batched | Batched |
| Native event listeners | Not batched | Batched |
| fetch callbacks | Not batched | Batched |
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
}
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.
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 })).
Production Scenario: Multiple Related State Updates
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
}
}
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.
-
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.
- 1setState is asynchronous — the state variable does not change until the next render
- 2Use functional updaters (prev => next) when the new state depends on the previous state
- 3React 18 batches ALL state updates — event handlers, timeouts, promises, everything
- 4Object.is comparison determines if React re-renders — same reference means no re-render
- 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:
setCount(count + 1)→setCount(0 + 1)→ queues value1setCount(count + 1)→setCount(0 + 1)→ queues value1
3. `setCount(prev => prev + 1)` → queues function updater
4. `setCount(prev => prev + 1)` → queues function updaterReact processes the queue starting from base state 0:
- Update 1: value
1→ state becomes1 - Update 2: value
1→ state becomes1(same value) - Update 3:
fn(1)→1 + 1→ state becomes2 - Update 4:
fn(2)→2 + 1→ state becomes3
The first two value updates both replace state with 1. The functional updaters then chain correctly: 1 → 2 → 3.