Skip to content

React 18 Automatic Batching and Transitions

advanced10 min read

The Batching Gap That React 18 Closed

This was one of the most annoying inconsistencies in React for years. Before React 18, batching only worked inside React event handlers. Updates in setTimeout, promises, and native event listeners each triggered a separate render:

// React 17: Multiple renders from async code
function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    // React 17 AND 18: Batched — one render
    setCount(c => c + 1);
    setFlag(f => !f);
  }

  function handleAsync() {
    fetch('/api/data').then(() => {
      // React 17: NOT batched — TWO renders!
      // React 18: Batched — one render
      setCount(c => c + 1);
      setFlag(f => !f);
    });
  }

  function handleTimeout() {
    setTimeout(() => {
      // React 17: NOT batched — TWO renders!
      // React 18: Batched — one render
      setCount(c => c + 1);
      setFlag(f => !f);
    }, 0);
  }
}

React 18 finally made batching consistent everywhere. Every state update is batched, regardless of where it originates. No more surprises.

The Mental Model

Mental Model

Think of batching like a waiter taking orders. In React 17, the waiter was attentive during dinner (React event handlers) — collecting all orders before going to the kitchen. But at the bar (setTimeout, promises), the waiter ran to the kitchen after every single item: "One beer! runs to kitchen One appetizer! runs to kitchen"

React 18 made the waiter consistent everywhere: collect all pending orders, then make one trip to the kitchen. Whether you're at the dinner table, the bar, or yelling from the parking lot (native event listeners), the waiter batches your orders.

flushSync is yelling "I need this RIGHT NOW" — the waiter immediately processes just that order before continuing to collect from others.

Where Batching Now Works

React 18's automatic batching applies everywhere:

function Dashboard() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Inside Promise.then — NOW BATCHED
    fetch('/api/dashboard')
      .then(res => res.json())
      .then(data => {
        setData(data);       // All three state updates
        setLoading(false);   // are batched into
        setError(null);      // a single render
      })
      .catch(err => {
        setData(null);
        setLoading(false);
        setError(err.message);
      });
  }, []);

  // Also batched in:
  // - setTimeout / setInterval callbacks
  // - Native DOM event listeners (addEventListener)
  // - async/await functions
  // - Web Worker message handlers
  // - IndexedDB transaction callbacks
}
Execution Trace
React 17:
`fetch().then(() = ( setA(1); setB(2); setC(3); ))`
3 separate renders. Component called 3 times. DOM updated 3 times
React 18:
`fetch().then(() = ( setA(1); setB(2); setC(3); ))`
1 render. Component called once with all 3 updates applied. DOM updated once
Savings:
2 fewer renders
Less CPU work, fewer DOM mutations, no intermediate inconsistent states

flushSync: The Escape Hatch

Sometimes you genuinely need a state update to commit to the DOM immediately -- before the next line of code runs. flushSync is your escape hatch for those rare cases:

import { flushSync } from 'react-dom';

function ChatInput() {
  const [messages, setMessages] = useState([]);
  const listRef = useRef(null);

  function handleSend(text) {
    // Without flushSync: scrollTo happens before the new message is in the DOM
    // The list scrolls to the second-to-last message

    flushSync(() => {
      setMessages(prev => [...prev, { text }]);
    });
    // DOM is now updated — the new message is in the list

    // This scroll now targets the correct bottom position
    listRef.current.scrollTop = listRef.current.scrollHeight;
  }

  return (
    <div>
      <div ref={listRef}>{messages.map(m => <Message key={m.id} msg={m} />)}</div>
      <input onKeyDown={e => e.key === 'Enter' && handleSend(e.target.value)} />
    </div>
  );
}

flushSync is heavy — it bypasses batching and forces a synchronous render cycle. Use it only when you need to read DOM state immediately after a state update.

Common Trap

flushSync only forces the update inside it to be synchronous. Other pending updates are still batched normally:

flushSync(() => {
  setA(1); // Rendered and committed immediately
});
// DOM has A=1 at this point

setB(2); // This is batched normally with any subsequent updates
setC(3); // B and C render together in the next batch

Also, flushSync inside a pending transition will force the transition lane to process synchronously — defeating the purpose of the transition.

Batching + Transitions: The Full Picture

Now let's see how these two React 18 features play together, because this is where it all comes together:

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [filter, setFilter] = useState('all');
  const [isPending, startTransition] = useTransition();

  function handleSearch(e) {
    const value = e.target.value;

    // These are batched into ONE synchronous render:
    setQuery(value);
    setFilter('all');

    // This is a SEPARATE render on TransitionLane:
    startTransition(() => {
      setResults(search(value));
    });
  }

  // Total renders from one keystroke:
  // 1. Sync render: query + filter update (batched)
  // 2. Transition render: results update (concurrent, interruptible)
}

The sync updates batch together. The transition creates a separate render. Without batching, the sync updates would be two renders. Without transitions, the results update would block the UI.

Production Scenario: The Triple-Render Bug

A team migrates from React 17 to 18 and notices their loading state logic breaks:

// Worked in React 17 (unintentionally), breaks in React 18
function DataFetcher() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  async function fetchData() {
    setLoading(true);
    // React 17: This causes a re-render (loading=true shown)
    // React 18: Batched with the updates below — loading=true never shown alone

    try {
      const res = await fetch('/api/data');
      const json = await res.json();
      setData(json);
      setLoading(false);
      // React 17: Two re-renders (data, then loading)
      // React 18: One re-render (data + loading together)
    } catch (err) {
      setError(err);
      setLoading(false);
    }
  }
}

In React 17, setLoading(true) caused an immediate render, showing the loading spinner. In React 18, it's batched with the subsequent updates after the await. The loading spinner might never appear because setLoading(true) and setLoading(false) are batched together.

The fix: React 18 batching works per microtask, not per function call. The await creates a microtask boundary, so setLoading(true) actually does render before the await.

// Actually correct in React 18 — await creates a yield point
async function fetchData() {
  setLoading(true);  // Render 1: loading = true (before await)

  const res = await fetch('/api/data'); // Microtask boundary

  setData(json);       // Render 2: data + loading batched
  setLoading(false);
}
Batching boundaries in React 18

React 18 batches updates within the same microtask. Here's when batching boundaries occur:

  1. Synchronous code: All setState calls in the same synchronous block are batched.
  2. After await: The code after await runs in a new microtask. Updates before and after await are in different batches.
  3. Promise .then(): Each .then() callback is a new microtask boundary.
  4. setTimeout callback: The callback is a new macrotask, starting a fresh batch.
  5. flushSync: Forces an immediate batch boundary.
// Same batch (synchronous):
setA(1); setB(2); setC(3); // 1 render

// Different batches (await boundary):
setA(1);
await somePromise;
setB(2); setC(3); // 2 total renders

// Different batches (flushSync boundary):
setA(1);
flushSync(() => setB(2)); // Renders A+B immediately
setC(3); // Separate render for C

Common Mistakes

Common Mistakes
  • Wrong: Relying on intermediate renders between multiple setState calls for side effects Right: Use useEffect for side effects that depend on state changes

  • Wrong: Using flushSync to fix all batching-related issues Right: Restructure code to work with batching. Use flushSync only for DOM measurement after update

  • Wrong: Expecting state to update after setState within the same synchronous scope Right: State updates are always processed in the next render, not immediately

  • Wrong: Assuming batching happens across different tick boundaries Right: Batching is per synchronous block. await, setTimeout, and microtask boundaries create separate batches

Challenge

Predict the render count

Show Answer

4 renders total (including initial):

  1. Initial render: render 0 0 0
  2. After click (sync batch): render 1 1 0setA(1) and setB(1) are batched (same synchronous block before await)
3. **After await**: `setC(1)` starts a new batch. Then `flushSync(() => setA(2))` forces an immediate render. But React processes the pending `setC(1)` with the flushSync together: `render 2 1 1`
  1. After flushSync: setB(2) and setC(2) are batched into one final render: render 2 2 2

Key insight: the await creates a microtask boundary. Everything before await is one batch, everything after starts fresh. flushSync forces any pending updates to render immediately alongside the flushed update.

Quiz

Quiz
In React 18, how many renders does this code produce?

Key Rules

Key Rules
  1. 1React 18 automatically batches all state updates everywhere — event handlers, setTimeout, promises, native events. Not just React event handlers.
  2. 2Batching boundaries are per synchronous execution block. await, setTimeout, and .then() callbacks start new batch contexts.
  3. 3flushSync forces immediate synchronous rendering. Use only when you need DOM state before the next line of code.
  4. 4Transitions create a separate render on a lower-priority lane. They are not batched with synchronous updates.
  5. 5State values don't change within the current closure. Even after flushSync, the local variables still hold old values. New values are in the next render.
  6. 6The batching behavior change from React 17 to 18 can break code that relied on intermediate renders — especially loading state patterns in async code.