State Updates & Batching
Calling a useState setter feels like assigning a variable, but it isn’t. React does not change your state value on the spot—it schedules an update and re-renders later. Within a single event handler, React also groups multiple setter calls into one render, a behavior called batching. Understanding this is the difference between a counter that increments by three and one that mysteriously only moves by one.
State updates are asynchronous
When you call setCount(count + 1), React records that you want a new value and queues a re-render. It does not mutate the count variable you can see in the current render. That variable is a snapshot—it holds the value from the render that created this function call, and it stays fixed for the lifetime of that render.
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // still the old value!
}
return <button onClick={handleClick}>Count: {count}</button>;
}
Output:
// On the first click, with count starting at 0:
0 // console.log runs before the re-render
// UI then re-renders and shows "Count: 1"
The console.log prints 0, not 1, because count in this render is permanently 0. The new value only exists on the next render.
React batches updates within an event
Because state is a snapshot, calling the same setter several times in one handler does not stack the way you might expect.
function handleClick() {
setCount(count + 1); // count is 0 -> schedules 1
setCount(count + 1); // count is STILL 0 -> schedules 1
setCount(count + 1); // count is STILL 0 -> schedules 1
}
Output:
// Click once, expecting +3:
Count: 1
All three calls read the same snapshot (count === 0), so each one schedules 1. React then batches them and performs a single re-render. This is the stale-state trap: reading the state variable repeatedly inside one event gives you a value that is already out of date.
Batching is a feature, not a bug. Re-rendering once per event instead of once per setter keeps the UI fast and avoids rendering inconsistent half-updated states.
The updater function
To build on the latest pending value instead of the snapshot, pass a function to the setter. React calls each queued updater in order, handing it the most recent value and using the return value as the next state.
function handleClick() {
setCount((prev) => prev + 1); // 0 -> 1
setCount((prev) => prev + 1); // 1 -> 2
setCount((prev) => prev + 1); // 2 -> 3
}
Output:
// Click once:
Count: 3
The convention is prev => next: the parameter is the pending value, and the return is what you want next. Use the updater form whenever the new state depends on the old state.
| Approach | Reads from | Three calls give | Use when |
|---|---|---|---|
setCount(count + 1) | This render’s snapshot | 1 | The next value is independent of current state |
setCount(prev => prev + 1) | The latest queued value | 3 | The next value is derived from the previous one |
React 18 automatic batching
Before React 18, batching only happened inside React event handlers. Updates fired from a setTimeout, a Promise .then, or a native event listener each triggered their own render. React 18’s createRoot enabled automatic batching everywhere—any number of updates in the same tick are grouped into one render, regardless of where they originate.
function SaveButton() {
const [saving, setSaving] = useState(false);
const [count, setCount] = useState(0);
async function handleSave() {
setSaving(true);
await fetch("/api/save", { method: "POST" });
// In React 18+, these two updates are batched into ONE render:
setSaving(false);
setCount((c) => c + 1);
}
return (
<button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : `Saved ${count}`}
</button>
);
}
Both setters after the await run in a single render pass. In React 17 the same code would have rendered twice. If you ever need to opt out and force a synchronous, separately-rendered update (rare), wrap it in flushSync from react-dom.
Best Practices
- Treat the state variable as a fixed snapshot for the duration of a render—never assume it changed right after a setter call.
- Use the updater form
set(prev => next)whenever the next value depends on the current value. - Don’t rely on reading state immediately after setting it; act on the new value in the next render or compute it locally first.
- Let React batch—don’t fight it by splitting logic across
setTimeoutjust to “force” extra renders. - Reach for
flushSynconly for genuine DOM-measurement edge cases, since it costs an extra synchronous render. - Keep setter calls inside event handlers and effects, never directly in the render body, to avoid infinite update loops.