Write this handler and guess how many renders happen:
function handleSignup() {
setEmail(emailInput);
setPassword(passwordInput);
setStep("confirm");
}One. React collects all three updates and commits a single render. Batching has been default behavior for years, yet I still review code written to defend against a problem that stopped existing, and missing the one place it still does.
Step 1: Understand what batching already does
Inside an event handler, React does not render per setState. It runs your function to completion, queues every update, and renders once with the combined result. Three state changes, one render, one commit, one paint.
Two consequences people get backwards. First, reading a state variable right after setting it gives you the old value, because the render has not happened yet. Second, performance "optimizations" that merge three useStates into one object to "avoid re-renders" save nothing. They just make the code harder to write.
Step 2: Count on batching in async code too
React 17 batched handler updates only. A setTimeout or promise callback leaked one render per setState. React 18 made automatic batching universal:
setTimeout(() => {
setA(1);
setB(2);
setC(3);
}, 1000);
// React 17: three renders. React 18+: one.The same covers await continuations, native event listeners, and fetch callbacks. unstable_batchedUpdates, the escape hatch some libraries shipped, is gone from the conversation because the default finally matches what everyone assumed it was.
Step 3: Stop syncing inputs with mirror state
The old form pattern: one useState per field, an onChange per input, and a reset function that clears eight states on submit. React 19 form actions make most of that scaffolding optional:
async function createOrder(formData: FormData) {
await submitOrder(formData);
// React resets the <form> automatically on successful action
}
<form action={createOrder}>
<input name="address" />
<button>Order</button>
</form>The uncontrolled input carries its own state, the action receives everything through FormData, and the reset is native. Fewer state variables means fewer batching puzzles to reason about, which is the cheapest optimization in this whole article.
Step 4: Batch around awaits deliberately
Here is the case that still produces two renders:
async function handleSave() {
setSaving(true);
await api.save(data);
setSaving(false);
}The two setSaving calls run in different ticks, so the component renders once to show the spinner and once to clear it. That is usually exactly what you want, and naming it is the point: batching groups updates within a tick, not across an await. If you ever need the before-and-after in a single commit, restructure so both updates land in the same continuation.
Step 5: Read state with the updater form when order matters
Batched updates queue in order, which trips people who read the variable mid-handler:
setCount(count + 1); // uses the stale count
setCount(count + 1); // still the same stale count
// result: count + 1, not count + 2
setCount((c) => c + 1);
setCount((c) => c + 1);
// result: count + 2, each updater sees the previous resultThe updater form threads each queued result into the next call. Any time two updates touch the same value in one handler, the function form is not a style preference, it is the difference between correct and off-by-one.
The habit that ties it together
Batching means you write handlers the obvious way: set what needs setting, in the order the logic reads, and let React commit once. The remaining bugs come from the two edges, stale reads mid-handler and renders split by an await, and both have one-line fixes.
Migrating a form-heavy React app to 19? The action-based patterns above are a big chunk of my recent client work. Tell me about the app and I will show you which forms collapse into actions first.