Fixing React useState Updates That Batch Silently in Async Event Handlers
You call setState twice inside an async function, but your component only re-renders once β and not with the values you expected. No error, no warning, just a UI that's one step behind reality. This is React's automatic batching at work, and in async contexts it trips up even experienced developers.
React batching is a feature, not a bug. But when it fires in places you don't expect, it produces subtle, hard-to-reproduce state bugs. This article explains exactly where the batching boundary sits, how to detect it, and three reliable ways to fix it.
What you'll learn
- How React decides when to batch
setStatecalls and when it doesn't - Why async handlers behave differently from synchronous event handlers
- How React 18 changed the batching rules and what that means for your code
- Three concrete fixes: state consolidation, functional updates, and
flushSync - Common pitfalls that make the problem harder to see in development
Prerequisites
You should be comfortable with React functional components and the useState hook. The code examples use React 18, but the concepts apply to React 17 with minor differences noted along the way. No special setup is required beyond a standard React project.
How React Batching Works in Synchronous Handlers
In a regular click handler, React collects every setState call and flushes them all in a single render pass. This is batching, and it exists to avoid redundant re-renders.
function Counter() {
const [count, setCount] = React.useState(0);
const [label, setLabel] = React.useState('');
function handleClick() {
setCount(c => c + 1); // batched
setLabel('updated'); // batched
// Only ONE re-render happens here
}
return <button onClick={handleClick}>{label}: {count}</button>;
}Both state updates above are collected before React touches the DOM. You get one render with both new values. That's exactly what you want from a performance standpoint.
Where Async Handlers Break the Batching Contract
The problem starts the moment you introduce an await. After an await expression resolves, React 17 and earlier lose the synthetic event context. Any setState calls made after that point are processed one at a time, triggering a separate render for each one.
// React 17 behavior β two separate renders after the await
async function handleSubmit() {
const data = await fetchUserData();
setUser(data); // render 1
setLoading(false); // render 2
}Between render 1 and render 2, your component exists in an intermediate state: the user data is loaded but loading is still true. If your UI shows a spinner based on loading, it flickers for one render cycle. On slower machines or in complex trees, that flicker is visible to users.
The same issue appears with setTimeout, Promise.then, and native event listeners added with addEventListener β anything that runs outside React's own event delegation system.
React 18 Changed the Rules (But Didn't Remove the Problem)
React 18 introduced automatic batching, which extends batching to async callbacks, timeouts, and native event listeners. In most cases, those two setState calls after an await will now batch correctly without any code changes on your part.
// React 18 with createRoot β this batches correctly now
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
async function handleSubmit() {
const data = await fetchUserData();
setUser(data); // }
setLoading(false); // } batched into one render in React 18
}The catch: this only applies if you've migrated to createRoot. If your project still uses the legacy ReactDOM.render, automatic batching is not active and React 17 behavior applies. Check your index.js or main.jsx to confirm which mode you're in.
Even with React 18, there are edge cases where automatic batching won't save you β particularly when you need two renders to be sequential for a reason, or when you're interacting with third-party libraries that expect synchronous DOM updates between state changes.
Diagnosing the Problem in Your Own Code
Before reaching for a fix, confirm that batching is actually your problem. The clearest diagnostic is adding a render counter to the component in question.
import { useRef } from 'react';
function MyForm() {
const renderCount = useRef(0);
renderCount.current += 1;
console.log('Render #', renderCount.current);
const [user, setUser] = React.useState(null);
const [loading, setLoading] = React.useState(false);
async function handleSubmit(e) {
e.preventDefault();
setLoading(true);
const data = await fetchUserData();
setUser(data);
setLoading(false);
}
// ...
}Open the browser console and watch the render count. If you see three separate renders for a single form submission (one for setLoading(true), one for setUser, one for setLoading(false)), batching is not working as you need it to. In React 17, that's expected. In React 18 with createRoot, setLoading(true) before the await will still be its own render β the batching only applies to calls on the same synchronous tick after the async operation resolves.
Fix 1: Consolidate Related State into a Single Object
The most maintainable fix is to group tightly related state variables into one object. One setState call means one render, full stop.
function MyForm() {
const [formState, setFormState] = React.useState({
user: null,
loading: false,
error: null,
});
async function handleSubmit(e) {
e.preventDefault();
setFormState(prev => ({ ...prev, loading: true }));
try {
const data = await fetchUserData();
setFormState({ user: data, loading: false, error: null }); // one render
} catch (err) {
setFormState(prev => ({ ...prev, loading: false, error: err.message }));
}
}
return (
<div>
{formState.loading && <Spinner />}
{formState.user && <UserCard user={formState.user} />}
{formState.error && <ErrorMessage msg={formState.error} />}
</div>
);
}This pattern works well for data-fetching components. The tradeoff is that every partial update requires a spread, and TypeScript types for the state object need to be maintained. For state that genuinely belongs together β loading, data, and error form a natural triad β this is the right default.
Fix 2: Use Functional Updates to Avoid Stale Closures
When you have multiple independent state variables and you want to keep them separate, functional updates at least guarantee you're reading the latest state at the time of each update. This doesn't reduce the number of renders, but it prevents the stale state bug where one update overwrites another.
async function handleLike(postId) {
// This is wrong β likeCount might be stale inside the closure
setLikeCount(likeCount + 1);
// This is correct β always reads the current value at update time
setLikeCount(prev => prev + 1);
await recordLike(postId);
setLikeCount(prev => prev + 1); // safe even after await
}Functional updates are a good habit regardless of async context. Any time your new state depends on the previous state, use the function form. The closure over the old value is one of the most common sources of subtle React bugs.
Fix 3: Force Synchronous Rendering with flushSync
flushSync, exported from react-dom, tells React to flush all queued state updates synchronously right now, before returning. Use it when you genuinely need a DOM update to complete before moving to the next line β for example, when you're measuring a freshly rendered element or coordinating with a non-React animation library.
import { flushSync } from 'react-dom';
async function handleAnimation() {
flushSync(() => {
setVisible(true); // React renders synchronously before continuing
});
// At this point, the DOM is updated
const height = ref.current.getBoundingClientRect().height;
setExpandedHeight(height);
await triggerServerAnimation(height);
}flushSync is a sharp tool. Overusing it defeats the purpose of batching and can tank performance in large trees. Reserve it for cases where you genuinely need the render to have committed before your next line of code runs. If you find yourself reaching for it everywhere, consolidating state is almost always the better answer.
Common Pitfalls and Gotchas
React 18 batching only activates with createRoot
Double-check your entry point. A React 18 project that was upgraded from React 17 but not fully migrated will still use ReactDOM.render and won't get automatic batching. Look for this in your main.jsx:
// Legacy β no automatic batching
ReactDOM.render(<App />, document.getElementById('root'));
// Modern β automatic batching active
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);StrictMode double-invocation hides the problem in development
React 18's StrictMode intentionally invokes components and hooks twice in development to help you catch side effects. This can mask batching issues by changing the render sequence. Always verify batching behavior in a production build or by temporarily disabling StrictMode when diagnosing.
Third-party libraries that call setState outside React
Libraries that dispatch events or callbacks outside React's event system β certain WebSocket clients, native browser APIs β bypass automatic batching even in React 18. Wrap their callbacks in React.startTransition or consolidate state to handle this case.
Stale closures compounding the problem
If your async handler closes over state values (not using functional updates), you can end up with incorrect state even when batching works perfectly. A correct render with stale data is just as wrong as an extra render. Always use the functional form of setState when the new value depends on the old one.
Wrapping Up
React's batching behavior in async handlers has changed meaningfully across versions, which makes this one of those bugs that looks different depending on when your project was written. Here are the concrete actions to take right now:
- Check your entry point. Confirm you're using
createRootif you're on React 18. If not, migrate β it's usually a two-line change and it unlocks automatic batching everywhere. - Add a render counter to the component that's behaving oddly. Count renders in the console and map them to specific
setStatecalls to confirm batching is the issue. - Group related state into a single object when the variables always change together. This is the most durable fix and reduces surface area for stale-state bugs.
- Switch to functional updates (
setState(prev => ...)) wherever the new value depends on the current value. Do this regardless of async context β it's always safer. - Use
flushSyncsparingly, only when you need a committed DOM update before the next line of synchronous code. Treat it as a last resort, not a default fix.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!