Short answer
Yes — with one condition. Under React 18's createRoot, multiple state updates inside a single asynchronous callback (a setTimeout handler, a promise .then, a native event listener) are batched into one render, and this is stable, documented behavior you can verify in a repeatable dev setup. StrictMode is not required to see it, and disabling StrictMode does not change the batching outcome — it only removes the double-invocation noise that makes render counts hard to read.
Confirmed facts vs. likely explanation
Confirmed behavior (React 18, createRoot):
- Updates inside promises, timeouts, and native handlers are batched, not just React synthetic event handlers as in React 17.
- Batching is scoped to a synchronous segment. Two
setState calls separated by an await are batched within each segment, not across the await boundary. flushSync opts a specific update out of batching when you need a synchronous render (e.g., before measuring layout).- If the entry point still uses legacy
ReactDOM.render, async batching silently does not apply — this is the most common reason it "doesn't work."
Likely explanation for inconsistent observations: a mixed or legacy root, an unpinned React version, or StrictMode double-rendering being mistaken for broken batching.
Your three specific questions
Does disabling StrictMode reveal the true behavior?
Disabling StrictMode removes double-invoked renders and effect re-runs, so your render counter reflects production-like behavior. But batching itself is identical either way — StrictMode only obscures the measurement. Keep StrictMode on for its bug-catching value and instead count commits (via the Profiler) rather than render-phase invocations.
Does startTransition change the render count?
No. startTransition marks updates as low priority and interruptible; it does not change whether they are batched. Two updates in one async callback produce one commit with or without it. It changes when the render is scheduled, not how many renders occur.
Should you depend on it for performance?
Depend on it as a rendering optimization, not as a correctness guarantee. Never encode business logic that assumes an exact render count — batching reduces renders but says nothing about effect ordering, and future concurrent features may adjust scheduling. Use functional updates (setState(prev => ...)) when new state derives from old, which is correct regardless of batching.
Verifying it in a repeatable setup
- Pin the version:
"react": "18.x" (exact, not caret) in package.json. - Confirm the entry point uses
ReactDOM.createRoot(container).render(<App />). - Add a render counter in the component body, then trigger two
setState calls inside one setTimeout or .then. Expect one commit in the React DevTools Profiler.
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
}, 0); // one render under createRoot in React 18
If you see two commits, check the root API call first — that, not batching itself, is almost always the culprit.