
React18 Batching Patterns
- 756 installs
- 37.1k repo stars
- Updated July 28, 2026
- github/awesome-copilot
react18-batching-patterns fixes React 18 automatic batching regressions in class components and related test failures.
About
React 18 Automatic Batching Patterns documents the silent breaking change where setState inside setTimeout, Promise handlers, async/await, and native event listeners now batches into a single re-render instead of flushing immediately as in React 17. A quick diagnosis tree classifies bugs into Category A silent state-read after await, Category B refactor without flushSync, or Category C when intermediate UI must be visible before async work continues. The flushSync rule limits synchronous re-renders to spinner or loading states and multi-step visible wizards because overuse bypasses the concurrent scheduler. Reference files batching-categories.md and flushSync-guide.md carry full before/after patterns for each category. The skill explicitly targets class components and test failures from intermediate state assertions after React 18 upgrade.
- React 18 batches setState in setTimeout, promises, async/await, and native listeners.
- Diagnosis tree: state read after await, refactor path, or flushSync requirement.
- Category A fixes silent bugs from reading this.state after await.
- flushSync only when users must see intermediate UI before async continues.
- Addresses test failures from intermediate state assertions post-upgrade.
React18 Batching Patterns by the numbers
- 756 all-time installs (skills.sh)
- +17 installs in the week ending Jul 19, 2026 (Skillselion tracking)
- Ranked #454 of 2,277 Frontend Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
react18-batching-patterns capabilities & compatibility
- Capabilities
- react 17 versus 18 batching behavior comparison · category a/b/c diagnosis decision tree · refactor guidance to avoid post await state read · selective flushsync usage rules · test failure remediation for batched renders
- Use cases
- testing · debugging · refactoring
What react18-batching-patterns says it does
Use `flushSync` sparingly.
npx skills add https://github.com/github/awesome-copilot --skill react18-batching-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 756 |
|---|---|
| repo stars | ★ 37.1k |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 28, 2026 |
| Repository | github/awesome-copilot ↗ |
Why do class components break after React 18 when setState runs inside async code or timers?
Diagnose and fix React 18 automatic batching regressions in class components with async setState, setTimeout, Promise handlers, and selective flushSync use.
Who is it for?
Teams upgrading class-component codebases to React 18.3.1 with async setState or failing intermediate assertions.
Skip if: Skip for function components with hooks only, dependency version matrices, or Enzyme migration work.
When should I use this skill?
Class component has multiple setState in async methods, timers, promises, or tests assert intermediate renders.
What you get
Category-based diagnosis with refactor-first fixes and sparing flushSync for required intermediate UI.
- Corrected async React handler code
Files
React 18 Automatic Batching Patterns
Reference for diagnosing and fixing the most dangerous silent breaking change in React 18 for class-component codebases.
The Core Change
| Location of setState | React 17 | React 18 |
|---|---|---|
| React event handler | Batched | Batched (same) |
| setTimeout | Immediate re-render | Batched |
| Promise .then() / .catch() | Immediate re-render | Batched |
| async/await | Immediate re-render | Batched |
| Native addEventListener callback | Immediate re-render | Batched |
Batched means: all setState calls within that execution context flush together in a single re-render at the end. No intermediate renders occur.
Quick Diagnosis
Read every async class method. Ask: does any code after an await read this.state to make a decision?
Code reads this.state after await?
YES → Category A (silent state-read bug)
NO, but intermediate render must be visible to user?
YES → Category C (flushSync needed)
NO → Category B (refactor, no flushSync)For the full pattern for each category, read:
- `references/batching-categories.md` - Category A, B, C with full before/after code
- `references/flushSync-guide.md` - when to use flushSync, when NOT to, import syntax
The flushSync Rule
Use `flushSync` sparingly. It forces a synchronous re-render, bypassing React 18's concurrent scheduler. Overusing it negates the performance benefits of React 18.
Only use flushSync when:
- The user must see an intermediate UI state before an async operation begins
- A spinner/loading state must render before a fetch starts
- Sequential UI steps have distinct visible states (progress wizard, multi-step flow)
In most cases, the fix is a refactor - restructuring the code to not read this.state after await. Read references/batching-categories.md for the correct approach per category.
Batching Categories - Before/After Patterns
Category A - this.state Read After Await (Silent Bug) {#category-a}
The method reads this.state after an await to make a conditional decision. In React 18, the intermediate setState hasn't flushed yet - this.state still holds the pre-update value.
Before (broken in React 18):
async handleLoadClick() {
this.setState({ loading: true }); // batched - not flushed yet
const data = await fetchData();
if (this.state.loading) { // ← still FALSE (old value)
this.setState({ data, loading: false }); // ← never called
}
}After - remove the this.state read entirely:
async handleLoadClick() {
this.setState({ loading: true });
try {
const data = await fetchData();
this.setState({ data, loading: false }); // always called - no condition needed
} catch (err) {
this.setState({ error: err, loading: false });
}
}Pattern: If the condition on this.state was always going to be true at that point (you just set it to true), remove the condition. The setState you called before await will eventually flush - you don't need to check it.
---
Category A Variant - Multi-Step Conditional Chain
// Before (broken):
async initialize() {
this.setState({ step: 'auth' });
const token = await authenticate();
if (this.state.step === 'auth') { // ← wrong: still initial value
this.setState({ step: 'loading', token });
const data = await loadData(token);
if (this.state.step === 'loading') { // ← wrong again
this.setState({ step: 'ready', data });
}
}
}// After - use local variables, not this.state, to track flow:
async initialize() {
this.setState({ step: 'auth' });
try {
const token = await authenticate();
this.setState({ step: 'loading', token });
const data = await loadData(token);
this.setState({ step: 'ready', data });
} catch (err) {
this.setState({ step: 'error', error: err });
}
}---
Category B - Independent setState Calls (Refactor, No flushSync) {#category-b}
Multiple setState calls in a Promise chain where order matters but no intermediate state reading occurs. The calls just need to be restructured.
Before:
handleSubmit() {
this.setState({ submitting: true });
submitForm(this.state.formData)
.then(result => {
this.setState({ result });
this.setState({ submitting: false }); // two setState in .then()
});
}After - consolidate setState calls:
async handleSubmit() {
this.setState({ submitting: true, result: null, error: null });
try {
const result = await submitForm(this.state.formData);
this.setState({ result, submitting: false });
} catch (err) {
this.setState({ error: err, submitting: false });
}
}Rule: Multiple setState calls in the same async context already batch in React 18. Consolidating into fewer calls is cleaner but not strictly required.
---
Category C - Intermediate Render Must Be Visible (flushSync) {#category-c}
The user must see an intermediate UI state (loading spinner, progress step) BEFORE an async operation starts. This is the only case where flushSync is the right answer.
Diagnostic question: "If the loading spinner didn't appear until after the fetch returned, would the UX be wrong?"
- YES →
flushSync - NO → refactor (Category A or B)
Before:
async processOrder() {
this.setState({ status: 'validating' }); // user must see this
await validateOrder(this.props.order);
this.setState({ status: 'charging' }); // user must see this
await chargeCard(this.props.card);
this.setState({ status: 'complete' });
}After - flushSync for each required intermediate render:
import { flushSync } from 'react-dom';
async processOrder() {
flushSync(() => {
this.setState({ status: 'validating' }); // renders immediately
});
await validateOrder(this.props.order);
flushSync(() => {
this.setState({ status: 'charging' }); // renders immediately
});
await chargeCard(this.props.card);
this.setState({ status: 'complete' }); // last - no flushSync needed
}Simple loading spinner case (most common):
import { flushSync } from 'react-dom';
async handleSearch() {
// User must see spinner before the fetch begins
flushSync(() => this.setState({ loading: true }));
const results = await searchAPI(this.state.query);
this.setState({ results, loading: false });
}---
setTimeout Pattern
// Before (React 17 - setTimeout fired immediate re-renders):
handleAutoSave() {
setTimeout(() => {
this.setState({ saving: true });
// React 17: re-render happened here
saveToServer(this.state.formData).then(() => {
this.setState({ saving: false, lastSaved: Date.now() });
});
}, 2000);
}// After (React 18 - all setState inside setTimeout batches):
handleAutoSave() {
setTimeout(async () => {
// If loading state must show before fetch - flushSync
flushSync(() => this.setState({ saving: true }));
await saveToServer(this.state.formData);
this.setState({ saving: false, lastSaved: Date.now() });
}, 2000);
}---
Test Patterns That Break Due to Batching
// Before (React 17 - intermediate state was synchronously visible):
it('shows saving indicator', () => {
render(<AutoSaveForm />);
fireEvent.change(input, { target: { value: 'new text' } });
expect(screen.getByText('Saving...')).toBeInTheDocument(); // ← sync check
});
// After (React 18 - use waitFor for intermediate states):
it('shows saving indicator', async () => {
render(<AutoSaveForm />);
fireEvent.change(input, { target: { value: 'new text' } });
await waitFor(() => expect(screen.getByText('Saving...')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Saved')).toBeInTheDocument());
});flushSync Guide
Import
import { flushSync } from 'react-dom';
// NOT from 'react' - it lives in react-domIf the file already imports from react-dom:
import ReactDOM from 'react-dom';
// Add named import:
import ReactDOM, { flushSync } from 'react-dom';Syntax
flushSync(() => {
this.setState({ ... });
});
// After this line, the re-render has completed synchronouslyMultiple setState calls inside one flushSync batch together into ONE synchronous render:
flushSync(() => {
this.setState({ step: 'loading' });
this.setState({ progress: 0 });
// These batch together → one render
});When to Use
✅ Use when the user must see a specific UI state BEFORE an async operation starts:
flushSync(() => this.setState({ loading: true }));
await expensiveAsyncOperation();✅ Use in multi-step progress flows where each step must visually complete before the next:
flushSync(() => this.setState({ status: 'validating' }));
await validate();
flushSync(() => this.setState({ status: 'processing' }));
await process();✅ Use in tests that must assert an intermediate UI state synchronously (avoid when possible - prefer waitFor).
When NOT to Use
❌ Don't use it to "fix" a reading-this.state-after-await bug - that's Category A (refactor instead):
// WRONG - flushSync doesn't fix this
flushSync(() => this.setState({ loading: true }));
const data = await fetchData();
if (this.state.loading) { ... } // still a race condition❌ Don't use it for every setState to "be safe" - it defeats React 18 concurrent rendering:
// WRONG - excessive flushSync
async handleClick() {
flushSync(() => this.setState({ clicked: true })); // unnecessary
flushSync(() => this.setState({ processing: true })); // unnecessary
const result = await doWork();
flushSync(() => this.setState({ result, done: true })); // unnecessary
}❌ Don't use it inside a useEffect or componentDidMount to trigger immediate state - it causes nested render cycles.
Performance Note
flushSync forces a synchronous render, which blocks the browser thread until the render completes. On slow devices or complex component trees, multiple flushSync calls in an async method will cause visible jank. Use sparingly.
If you find yourself adding more than 2 flushSync calls to a single method, reconsider whether the component's state model needs redesign.
Related skills
How it compares
Use react18-batching-patterns for concrete React 18 batching before/after snippets; reach for general React docs when learning batching theory without migration bugs.
FAQ
setTimeout setState behavior in React 18?
Batched into one re-render at end of execution context, unlike immediate renders in React 17.
When use flushSync?
Only when users must see loading or intermediate UI before async work; refactor is preferred in most cases.
Code reads this.state after await?
Category A silent bug; restructure so decisions do not depend on stale state after await.
Is React18 Batching Patterns safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.