Installation

Install with CLI Recommended
gh skills-hub install react18-batching-patterns

Don't have the extension? Run gh extension install samueltauil/skills-hub first.

Download and extract to your repository:

.github/skills/react18-batching-patterns/

Extract the ZIP to .github/skills/ in your repo. The folder name must match react18-batching-patterns for Copilot to auto-discover it.

Skill Files (3)

SKILL.md 2.4 KB
---
name: react18-batching-patterns
description: 'Provides exact patterns for diagnosing and fixing automatic batching regressions in React 18 class components. Use this skill whenever a class component has multiple setState calls in an async method, inside setTimeout, inside a Promise .then() or .catch(), or in a native event handler. Use it before writing any flushSync call - the decision tree here prevents unnecessary flushSync overuse. Also use this skill when fixing test failures caused by intermediate state assertions that break after React 18 upgrade.'
---

# 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.
references/
batching-categories.md 5.8 KB
# 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):**

```jsx
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:**

```jsx
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

```jsx
// 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 });
    }
  }
}
```

```jsx
// 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:**

```jsx
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:**

```jsx
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:**

```jsx
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:**

```jsx
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):

```jsx
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

```jsx
// 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);
}
```

```jsx
// 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

```jsx
// 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.md 2.4 KB
# flushSync Guide

## Import

```jsx
import { flushSync } from 'react-dom';
// NOT from 'react' - it lives in react-dom
```

If the file already imports from `react-dom`:

```jsx
import ReactDOM from 'react-dom';
// Add named import:
import ReactDOM, { flushSync } from 'react-dom';
```

## Syntax

```jsx
flushSync(() => {
  this.setState({ ... });
});
// After this line, the re-render has completed synchronously
```

Multiple setState calls inside one flushSync batch together into ONE synchronous render:

```jsx
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:

```jsx
flushSync(() => this.setState({ loading: true }));
await expensiveAsyncOperation();
```

โœ… Use in multi-step progress flows where each step must visually complete before the next:

```jsx
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):

```jsx
// 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:

```jsx
// 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.

License (MIT)

View full license text
MIT License

Copyright GitHub, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.