Episode 2 — React Frontend Architecture NextJS / 2.3 — State and Rerendering Logic

2.3.d — Batching State Updates

In one sentence: Batching is React's optimization of grouping multiple state updates into a single re-render, so calling setState three times in a row results in one re-render instead of three.

Navigation: ← 2.3.c — How React Re-renders · Next → 2.3.e — Derived State


Table of Contents

  1. What Is Batching
  2. Pre-React 18 Batching
  3. React 18 Automatic Batching
  4. Why Batching Improves Performance
  5. How Batching Works Internally
  6. Multiple setState Calls in One Handler
  7. Batching with Async/Await
  8. The State Update Queue
  9. Processing Order of Batched Updates
  10. flushSync: Opting Out of Batching
  11. Batching and Functional Updates
  12. Debugging Batched Updates
  13. Performance Impact Measurements
  14. Common Misconceptions
  15. Practical Examples
  16. Key Takeaways
  17. Explain-It Challenge

1. What Is Batching

Batching means React groups multiple state updates together and processes them in a single re-render cycle instead of re-rendering after each individual update.

function handleClick() {
  setCount(1);     // Does NOT re-render yet
  setIsOpen(true); // Does NOT re-render yet
  setName("Alice"); // Does NOT re-render yet
  // React re-renders ONCE here, after the handler finishes
}

Without batching, each setState call would trigger a separate re-render:

Without batching (hypothetical):
  setCount(1)     --> re-render #1 (count=1, isOpen=false, name="")
  setIsOpen(true) --> re-render #2 (count=1, isOpen=true, name="")
  setName("Alice") --> re-render #3 (count=1, isOpen=true, name="Alice")
  
  3 re-renders, 3 DOM diffs, 3 potential paints
  Also shows intermediate states that might be visually wrong

With batching (actual React behavior):
  setCount(1)      --> queued
  setIsOpen(true)  --> queued
  setName("Alice") --> queued
  [end of handler] --> ONE re-render (count=1, isOpen=true, name="Alice")
  
  1 re-render, 1 DOM diff, 1 paint
  User never sees intermediate states

Why Intermediate States Matter

Consider a form that tracks both a value and its validation status:

function handleSubmit() {
  setIsSubmitting(true);
  setErrors([]);
  setFormData(processedData);
}

Without batching, the component would briefly render in a state where isSubmitting is true but errors still has old values and formData is stale. This could cause visual glitches or even bugs. Batching ensures the user only sees the final, consistent state.


2. Pre-React 18 Batching

Before React 18, batching only worked inside React event handlers. Code running in other contexts (promises, timeouts, native event handlers) was NOT batched.

Batched (React 16/17)

function handleClick() {
  // Inside a React event handler -- BATCHED
  setCount(prev => prev + 1);
  setFlag(prev => !prev);
  // One re-render at the end
}

Not Batched (React 16/17)

function handleClick() {
  // Start of React event handler
  setCount(prev => prev + 1);
  setFlag(prev => !prev);
  // End of React event handler -- one re-render here (batched)

  setTimeout(() => {
    // Inside setTimeout -- NOT in a React event handler
    setCount(prev => prev + 1); // Re-render #1
    setFlag(prev => !prev);      // Re-render #2
    // Two separate re-renders!
  }, 1000);
}

// Same problem with promises:
async function handleClick() {
  setCount(prev => prev + 1);
  setFlag(prev => !prev);
  // Batched here

  const data = await fetchData();
  // Everything after await is outside the original event handler's batch scope

  setCount(data.count);    // Re-render #1
  setFlag(data.flag);       // Re-render #2
  // NOT batched in React 16/17!
}

Where Batching Worked in React 16/17

ContextBatched?
React event handlers (onClick, onChange)Yes
Inside useEffectYes
setTimeout/setInterval callbacksNo
Promise .then() callbacksNo
After await in async functionsNo
Native DOM event listenersNo
fetch/XMLHttpRequest callbacksNo

This inconsistency was a major pain point.


3. React 18 Automatic Batching

React 18 introduced automatic batching, which batches state updates in ALL contexts. This is one of the most impactful React 18 improvements.

Everything Is Batched Now

// React 18: ALL of these are batched

// 1. Event handlers (same as before)
function handleClick() {
  setCount(prev => prev + 1);
  setFlag(prev => !prev);
  // One re-render
}

// 2. setTimeout
setTimeout(() => {
  setCount(prev => prev + 1);
  setFlag(prev => !prev);
  // One re-render (NEW in React 18!)
}, 1000);

// 3. Promises
fetch("/api/data").then(data => {
  setCount(data.count);
  setFlag(data.flag);
  // One re-render (NEW in React 18!)
});

// 4. Async/await
async function handleSubmit() {
  const data = await fetchData();
  setCount(data.count);
  setFlag(data.flag);
  // One re-render (NEW in React 18!)
}

// 5. Native event handlers
element.addEventListener("click", () => {
  setCount(prev => prev + 1);
  setFlag(prev => !prev);
  // One re-render (NEW in React 18!)
});

The Prerequisite: createRoot

Automatic batching requires using the new createRoot API:

// React 18 with automatic batching:
import { createRoot } from "react-dom/client";

const root = createRoot(document.getElementById("root"));
root.render(<App />);

// Legacy (no automatic batching outside event handlers):
import { render } from "react-dom";
render(<App />, document.getElementById("root"));

If you're using createRoot (which all new React 18+ apps should), automatic batching is on by default.

Summary: React 16/17 vs React 18

React 16/17:

  Event Handler:     [set, set, set] --> 1 re-render  (batched)
  setTimeout:        [set] --> render, [set] --> render  (not batched)
  Promise:           [set] --> render, [set] --> render  (not batched)
  Native listener:   [set] --> render, [set] --> render  (not batched)

React 18:

  Event Handler:     [set, set, set] --> 1 re-render  (batched)
  setTimeout:        [set, set, set] --> 1 re-render  (batched!)
  Promise:           [set, set, set] --> 1 re-render  (batched!)
  Native listener:   [set, set, set] --> 1 re-render  (batched!)
  Anywhere else:     [set, set, set] --> 1 re-render  (batched!)

4. Why Batching Improves Performance

Fewer Re-renders

The most obvious benefit: fewer re-renders mean less work.

Without batching: 3 state changes = 3 re-renders
  Render 1: Call component, diff, commit
  Render 2: Call component, diff, commit
  Render 3: Call component, diff, commit
  Total: 3x (component execution + diffing + DOM updates)

With batching: 3 state changes = 1 re-render
  Render 1: Call component with all 3 changes applied, diff, commit
  Total: 1x everything

Fewer DOM Operations

Each re-render produces a virtual DOM diff. With batching, you compute one diff that includes all changes, rather than three diffs with incremental changes. The combined diff often requires fewer DOM operations.

No Intermediate States

Batching prevents the user from seeing partial updates. This isn't just a performance benefit -- it's a correctness benefit.

function PriceDisplay() {
  const [price, setPrice] = useState(10);
  const [quantity, setQuantity] = useState(1);

  // Without batching, after setPrice but before setQuantity:
  // User might see price=20, quantity=1 (wrong total!)

  // With batching, both update together:
  // User sees price=20, quantity=5 (correct)

  function handleBulkUpdate() {
    setPrice(20);
    setQuantity(5);
  }

  return <p>Total: ${price * quantity}</p>;
}

Fewer Browser Reflows/Repaints

Each DOM update can trigger browser layout recalculation (reflow) and repaint. Fewer DOM updates = fewer reflows = smoother UI.

Without batching:
  DOM update 1 --> Reflow --> Repaint
  DOM update 2 --> Reflow --> Repaint
  DOM update 3 --> Reflow --> Repaint
  
With batching:
  DOM update (combined) --> Reflow --> Repaint

5. How Batching Works Internally

Understanding the mechanism helps you predict batching behavior.

The Execution Context

React uses an internal flag to track whether it's currently inside a batching context. In pseudo-code:

// Simplified internal mechanism

let isBatchingUpdates = false;
let pendingUpdates = [];

function batchedUpdates(fn) {
  isBatchingUpdates = true;
  fn();                           // Your event handler runs here
  isBatchingUpdates = false;
  processPendingUpdates();        // Now process all queued updates
}

function setState(update) {
  if (isBatchingUpdates) {
    pendingUpdates.push(update);  // Queue it
  } else {
    processUpdate(update);        // Process immediately
  }
}

function processPendingUpdates() {
  // Apply all updates, then re-render ONCE
  let finalState = currentState;
  for (const update of pendingUpdates) {
    finalState = applyUpdate(finalState, update);
  }
  reRender(finalState);
  pendingUpdates = [];
}

React 18's Approach

React 18 uses a more sophisticated mechanism based on "lanes" and microtask scheduling:

React 18 batching mechanism:

1. setState() called
   --> Create an update object
   --> Add to the fiber's update queue
   --> Schedule a microtask to flush (if not already scheduled)

2. More setState() calls
   --> Add more updates to their respective queues
   --> Microtask already scheduled, no new scheduling needed

3. Current synchronous code finishes (event handler, timeout callback, etc.)

4. Microtask fires
   --> React processes ALL queued updates
   --> Computes new state for all affected components
   --> Re-renders affected subtrees ONCE

The key insight: React 18 uses microtask scheduling (similar to queueMicrotask or Promise.resolve().then()). Microtasks fire after the current synchronous code finishes but before the browser paints. This naturally batches all synchronous setState calls.

Event loop visualization:

[Click handler starts]
  setCount(1)       --> queued
  setName("Alice")  --> queued
  setIsOpen(true)   --> queued
[Click handler ends]
[Microtask: React processes all queued updates]
[React re-renders ONCE]
[Browser paints]

6. Multiple setState Calls in One Handler

The most common batching scenario.

Basic Example

function UserSettings() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [theme, setTheme] = useState("light");
  const [notifications, setNotifications] = useState(true);

  function handleReset() {
    // All four updates are batched into one re-render
    setName("");
    setEmail("");
    setTheme("light");
    setNotifications(true);
    // Component re-renders ONCE with all four values reset
  }

  function handleLoadProfile(profile) {
    setName(profile.name);
    setEmail(profile.email);
    setTheme(profile.theme);
    setNotifications(profile.notifications);
    // One re-render with all values from the profile
  }

  console.log("Render:", { name, email, theme, notifications });

  return (
    <div>
      <button onClick={handleReset}>Reset</button>
      <button onClick={() => handleLoadProfile({
        name: "Alice",
        email: "alice@example.com",
        theme: "dark",
        notifications: false,
      })}>
        Load Profile
      </button>
    </div>
  );
}

When handleReset is called, you see ONE console.log, not four:

Render: { name: "", email: "", theme: "light", notifications: true }

Mixing Direct and Functional Updates

function handleClick() {
  setCount(42);                     // Direct: set to 42
  setName(prev => prev.trim());     // Functional: trim previous name
  setItems(prev => [...prev, newItem]); // Functional: append item
  // All three are batched into one re-render
}

Multiple Updates to the Same State

function handleClick() {
  setCount(1);   // queue: set to 1
  setCount(2);   // queue: set to 2
  setCount(3);   // queue: set to 3
  // Result: count = 3 (last direct value wins)
}

function handleClickFunctional() {
  setCount(prev => prev + 1);  // queue: increment
  setCount(prev => prev + 1);  // queue: increment
  setCount(prev => prev + 1);  // queue: increment
  // Result: count = previousCount + 3 (all three applied sequentially)
}

7. Batching with Async/Await

In React 18, batching works across async boundaries -- but with a nuance.

Synchronous Code Before and After Await

async function handleSubmit() {
  // --- Synchronous block 1 ---
  setIsSubmitting(true);
  setError(null);
  // These are batched together (one re-render)

  const data = await submitForm(formData);
  // await suspends execution. React flushes the batch above.
  // Re-render happens here with isSubmitting=true, error=null

  // --- Synchronous block 2 ---
  setIsSubmitting(false);
  setResult(data);
  // These are batched together (one re-render)
}
Timeline:

setIsSubmitting(true)  --> queued
setError(null)         --> queued
await submitForm(...)  --> Batch 1 flushes: 1 re-render
   ... waiting for API ...
setIsSubmitting(false) --> queued
setResult(data)        --> queued
[end of function]      --> Batch 2 flushes: 1 re-render

Total: 2 re-renders (one before await, one after)

Multiple Awaits

async function handleMultiStep() {
  setStep(1);
  setLoading(true);
  // Batch 1: re-render (step=1, loading=true)

  const userData = await fetchUser();
  setUser(userData);
  setStep(2);
  // Batch 2: re-render (user=data, step=2)

  const posts = await fetchPosts(userData.id);
  setPosts(posts);
  setStep(3);
  setLoading(false);
  // Batch 3: re-render (posts=data, step=3, loading=false)
}

Each await creates a new synchronous block, and each block is batched independently. Three await statements = up to four batches (before first await + after each await).

Promise Chains

function handleFetch() {
  setLoading(true);

  fetch("/api/data")
    .then(response => response.json())
    .then(data => {
      // Inside .then() -- still batched in React 18
      setData(data);
      setLoading(false);
      setError(null);
      // One re-render for all three
    })
    .catch(err => {
      setError(err.message);
      setLoading(false);
      // One re-render for both
    });
}

8. The State Update Queue

Each useState hook has an internal queue of pending updates. Understanding this queue explains batching behavior.

Queue Processing

State: count = 0

Event handler:
  setCount(5)              --> Queue: [{ type: "direct", value: 5 }]
  setCount(prev => prev+1) --> Queue: [{ type: "direct", value: 5 },
                                        { type: "function", fn: prev => prev+1 }]
  setCount(prev => prev*2) --> Queue: [{ type: "direct", value: 5 },
                                        { type: "function", fn: prev => prev+1 },
                                        { type: "function", fn: prev => prev*2 }]

Processing (after handler finishes):
  Start: count = 0
  Update 1 (direct): count = 5        (replace with 5)
  Update 2 (function): count = 5+1 = 6 (apply function to current)
  Update 3 (function): count = 6*2 = 12 (apply function to current)
  Final: count = 12

Direct Values Replace, Functions Transform

function example() {
  // Starting count: 0
  
  setCount(10);                  // Queue: [10]
  setCount(prev => prev + 5);   // Queue: [10, fn(+5)]
  setCount(20);                  // Queue: [10, fn(+5), 20]
  setCount(prev => prev + 1);   // Queue: [10, fn(+5), 20, fn(+1)]
  
  // Processing:
  // 10        --> count = 10
  // fn(+5)    --> count = 10 + 5 = 15
  // 20        --> count = 20 (direct value replaces!)
  // fn(+1)    --> count = 20 + 1 = 21
  
  // Final: count = 21
}

Multiple Hooks, Separate Queues

function Component() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("");

  function handleClick() {
    setCount(prev => prev + 1);   // count queue: [fn(+1)]
    setName("Alice");              // name queue: ["Alice"]
    setCount(prev => prev + 1);   // count queue: [fn(+1), fn(+1)]
    // Each hook has its own independent queue
  }
  // After processing:
  // count: 0 --> fn(+1) --> 1 --> fn(+1) --> 2
  // name: "" --> "Alice"
}

9. Processing Order of Batched Updates

The order in which you call setState matters because the queue is processed in order.

Order Matters for the Same State

// Example 1: Order affects the result
setCount(0);                    // Reset to 0
setCount(prev => prev + 10);   // 0 + 10 = 10
// Result: 10

// Example 2: Different order, different result
setCount(prev => prev + 10);   // (whatever count is) + 10
setCount(0);                    // Reset to 0
// Result: 0 (the reset overwrites the increment)

Order Doesn't Matter for Different States

// These produce the same result regardless of order:
setName("Alice");
setCount(42);

// Same as:
setCount(42);
setName("Alice");

// They're in separate queues, so order between them is irrelevant

Render Sees the Final State

function Component() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("");

  function handleClick() {
    setCount(1);
    setName("Alice");
    setCount(2);
    setName("Bob");
    setCount(3);
  }
  
  // On the next render:
  // count = 3 (last direct value)
  // name = "Bob" (last direct value)
  
  console.log("Render:", count, name);
  // You only see ONE log: "Render: 3 Bob"
  // You never see intermediate values
}

10. flushSync: Opting Out of Batching

In rare cases, you need a state update to be applied immediately, before the next line of code runs. flushSync forces React to flush the pending batch synchronously.

Syntax

import { flushSync } from "react-dom";

function handleClick() {
  flushSync(() => {
    setCount(prev => prev + 1);
  });
  // DOM is updated HERE, before the next line

  // This runs after the re-render caused by setCount
  console.log("Count is now:", countRef.current?.textContent);
}

When You Might Need flushSync

Scenario 1: Reading DOM measurements after a state update

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

  function handleNewMessage(message) {
    flushSync(() => {
      setMessages(prev => [...prev, message]);
    });
    // DOM is now updated with the new message
    // Safe to scroll to bottom
    listRef.current.scrollTop = listRef.current.scrollHeight;
  }

  return (
    <ul ref={listRef}>
      {messages.map(msg => <li key={msg.id}>{msg.text}</li>)}
    </ul>
  );
}

Without flushSync, the scroll would happen before the new message is in the DOM.

Scenario 2: Coordinating with third-party code that reads the DOM

function handleUpdate() {
  flushSync(() => {
    setData(newData);
  });
  // DOM reflects newData now
  thirdPartyLibrary.measureLayout(); // Reads actual DOM
}

flushSync Breaks Batching

function handleClick() {
  flushSync(() => {
    setCount(1);  // Flushed immediately --> re-render #1
  });

  flushSync(() => {
    setName("Alice");  // Flushed immediately --> re-render #2
  });

  setTheme("dark");
  setFlag(true);
  // These are batched together --> re-render #3

  // Total: 3 re-renders instead of 1
}

flushSync with Multiple Updates Inside

Updates within a single flushSync call are still batched together:

flushSync(() => {
  setCount(1);
  setName("Alice");
  // These two are batched into one re-render
  // That re-render happens synchronously before flushSync returns
});

Use flushSync Sparingly

flushSync is an escape hatch. Overuse defeats the purpose of batching:

// BAD: Using flushSync everywhere (defeats batching)
function handleClick() {
  flushSync(() => setCount(1));
  flushSync(() => setName("Alice"));
  flushSync(() => setTheme("dark"));
  // 3 re-renders. You've manually defeated batching.
}

// GOOD: Use batching (default behavior)
function handleClick() {
  setCount(1);
  setName("Alice");
  setTheme("dark");
  // 1 re-render. Let React batch.
}

// ACCEPTABLE: flushSync when you genuinely need synchronous DOM updates
function handleAddItem() {
  flushSync(() => {
    setItems(prev => [...prev, newItem]);
  });
  listRef.current.lastChild.scrollIntoView();
}

11. Batching and Functional Updates

Functional updates interact with batching in a specific way. Understanding this prevents common bugs.

Direct Values: Last One Wins

function handleClick() {
  setCount(1);  // queued: replace with 1
  setCount(2);  // queued: replace with 2
  setCount(3);  // queued: replace with 3
  // Processing: 1 -> 2 -> 3
  // Final: 3
}

Functional Updates: All Apply Sequentially

function handleClick() {
  setCount(prev => prev + 1);  // queued: fn(+1)
  setCount(prev => prev + 1);  // queued: fn(+1)
  setCount(prev => prev + 1);  // queued: fn(+1)
  // Processing (start at 0): 0+1=1 -> 1+1=2 -> 2+1=3
  // Final: 3
}

Mixed Direct and Functional

function handleClick() {
  setCount(prev => prev + 1);  // fn(+1)
  setCount(10);                 // replace with 10
  setCount(prev => prev + 1);  // fn(+1)
  
  // Processing (start at 0):
  // fn(+1): 0 + 1 = 1
  // 10: replace, count = 10
  // fn(+1): 10 + 1 = 11
  // Final: 11
}

Practical Implication

function Counter() {
  const [count, setCount] = useState(0);

  function incrementByThree() {
    // Using the closure value (BAD for this use case):
    setCount(count + 1); // 0 + 1 = 1
    setCount(count + 1); // 0 + 1 = 1 (count is still 0!)
    setCount(count + 1); // 0 + 1 = 1
    // Result: 1

    // Using functional updates (GOOD):
    setCount(prev => prev + 1); // 0 + 1 = 1
    setCount(prev => prev + 1); // 1 + 1 = 2
    setCount(prev => prev + 1); // 2 + 1 = 3
    // Result: 3
  }
}

This is the most practical reason to understand batching: it explains why setCount(count + 1) three times gives 1, while setCount(prev => prev + 1) three times gives 3.


12. Debugging Batched Updates

Technique 1: Console.log in the Component Body

function DebugComponent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("");

  // This logs on every render
  console.log("RENDER:", { count, name });

  function handleClick() {
    console.log("HANDLER START");
    setCount(1);
    setName("Alice");
    console.log("HANDLER END");
  }

  return <button onClick={handleClick}>Click</button>;
}

Output when clicked:

HANDLER START
HANDLER END
RENDER: { count: 1, name: "Alice" }

Notice: the render happens AFTER the handler completes. Both updates were batched.

Technique 2: useEffect to Track State Changes

function DebugComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("count changed to:", count);
  }, [count]);
  // This fires after each render where count changed
  // Shows you the final batched value, not intermediate ones
}

Technique 3: React DevTools Profiler

Record a profiling session, then examine:

  • How many renders occurred
  • Which components rendered
  • What caused each render
Profiler for a click event:

Commit 1 (the only one, thanks to batching):
  App
    Counter (state changed: count, name)
      Display (parent re-rendered)
    Sidebar (parent re-rendered)

Without batching, you'd see 2+ commits here.

Technique 4: Counting Renders

function RenderCounter() {
  const renderCount = useRef(0);
  renderCount.current += 1;

  const [a, setA] = useState(0);
  const [b, setB] = useState(0);
  const [c, setC] = useState(0);

  function handleClick() {
    setA(1);
    setB(2);
    setC(3);
  }

  console.log("Render #", renderCount.current);
  // With batching: you see render count increment by 1 per click
  // Without batching: it would increment by 3 per click

  return <button onClick={handleClick}>Update all</button>;
}

13. Performance Impact Measurements

Measuring the Difference

function PerformanceMeasure() {
  const [items, setItems] = useState(Array.from({ length: 1000 }, (_, i) => i));
  const renderStart = useRef(0);

  console.log("Render time:", performance.now() - renderStart.current, "ms");

  function handleBatchedUpdate() {
    renderStart.current = performance.now();
    setItems(prev => prev.map(i => i + 1));
    setItems(prev => prev.map(i => i * 2));
    setItems(prev => prev.filter(i => i % 3 !== 0));
    // One re-render
  }

  return (
    <div>
      <button onClick={handleBatchedUpdate}>Update (batched)</button>
      <ul>{items.slice(0, 10).map(i => <li key={i}>{i}</li>)}</ul>
    </div>
  );
}

Typical Performance Numbers

For a moderately complex component tree:

Scenario: Update 3 state variables in one handler

Without batching (measured):
  Re-render 1: 2ms
  Re-render 2: 2ms  
  Re-render 3: 2ms
  DOM operations: 3 separate diffs
  Total: ~8ms

With batching:
  Re-render 1: 2.5ms (slightly more work, all 3 changes)
  DOM operations: 1 combined diff
  Total: ~3ms

Improvement: ~60% faster

The improvement scales with:

  • Number of state updates batched together
  • Complexity of the component tree
  • Amount of DOM changes

When Batching Matters Most

High impact:
  - Forms with many fields updating simultaneously
  - Data loading (loading state + data + error state)
  - Multi-step state machines
  - Drag and drop (position + hover state + drop target)

Low impact:
  - Single state update per interaction
  - Simple components with minimal render cost
  - Updates that don't cause DOM changes

14. Common Misconceptions

Misconception 1: "Each setState causes a re-render"

Reality: In React 18, multiple setState calls in the same synchronous execution context are batched. You get one re-render at the end.

function handleClick() {
  setA(1); // No re-render yet
  setB(2); // No re-render yet
  setC(3); // No re-render yet
} // ONE re-render happens here

Misconception 2: "Batching only works in event handlers"

Reality: That was true in React 16/17. In React 18, batching works everywhere: event handlers, timeouts, promises, intervals, native events.

Misconception 3: "Batching delays state updates"

Reality: Batching doesn't add meaningful delay. The re-render happens at the end of the current synchronous block, which is microseconds after the last setState call.

setCount(1)    [time: 0.000ms]
setName("A")   [time: 0.001ms]
setFlag(true)  [time: 0.002ms]
[batch flush]  [time: 0.003ms]  <-- virtually instant

The "delay" is less than 1ms. Imperceptible to humans.

Misconception 4: "flushSync makes everything synchronous"

Reality: flushSync forces the current batch to be processed synchronously, but it doesn't change how React's rendering works overall. It's a targeted override, not a global mode change.

Misconception 5: "I need to manually batch updates"

Reality: In React 18, you don't. Batching is automatic. Before React 18, there was unstable_batchedUpdates from react-dom, but that's no longer needed.

// React 16/17 workaround (no longer needed):
import { unstable_batchedUpdates } from "react-dom";

setTimeout(() => {
  unstable_batchedUpdates(() => {
    setCount(1);
    setName("Alice");
  });
}, 1000);

// React 18: Just write it naturally
setTimeout(() => {
  setCount(1);
  setName("Alice");
}, 1000);

Misconception 6: "Setting the same state value twice causes two re-renders"

Reality: Setting state to the same value (by reference for objects, by value for primitives) causes zero re-renders.

const [count, setCount] = useState(0);
setCount(0); // Same value -- React bails out, no re-render

const [user, setUser] = useState(userObj);
setUser(userObj); // Same reference -- no re-render
setUser({ ...userObj }); // Different reference -- re-render (even if contents are identical)

15. Practical Examples

Example 1: Form Submission with Loading State

function ContactForm() {
  const [formData, setFormData] = useState({ name: "", email: "", message: "" });
  const [status, setStatus] = useState("idle");
  const [error, setError] = useState(null);

  async function handleSubmit(e) {
    e.preventDefault();

    // Batch 1: Set loading state
    setStatus("submitting");
    setError(null);
    // One re-render: shows loading UI

    try {
      const response = await fetch("/api/contact", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(formData),
      });

      if (!response.ok) throw new Error("Failed to send");

      // Batch 2: Set success state
      setStatus("success");
      setFormData({ name: "", email: "", message: "" });
      // One re-render: shows success + cleared form

    } catch (err) {
      // Batch 3 (only on error): Set error state
      setStatus("error");
      setError(err.message);
      // One re-render: shows error message
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={formData.name}
        onChange={e => setFormData(prev => ({ ...prev, name: e.target.value }))}
      />
      <input
        value={formData.email}
        onChange={e => setFormData(prev => ({ ...prev, email: e.target.value }))}
      />
      <textarea
        value={formData.message}
        onChange={e => setFormData(prev => ({ ...prev, message: e.target.value }))}
      />
      <button disabled={status === "submitting"}>
        {status === "submitting" ? "Sending..." : "Send"}
      </button>
      {status === "error" && <p>{error}</p>}
      {status === "success" && <p>Message sent.</p>}
    </form>
  );
}

Example 2: Drag and Drop Coordinates

function DraggableBox() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [isDragging, setIsDragging] = useState(false);
  const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });

  function handleMouseDown(e) {
    // Three state updates, one re-render
    setIsDragging(true);
    setDragOffset({
      x: e.clientX - position.x,
      y: e.clientY - position.y,
    });
  }

  function handleMouseMove(e) {
    if (!isDragging) return;
    // One state update (object), one re-render
    setPosition({
      x: e.clientX - dragOffset.x,
      y: e.clientY - dragOffset.y,
    });
  }

  function handleMouseUp() {
    setIsDragging(false);
    // One re-render
  }

  return (
    <div
      onMouseDown={handleMouseDown}
      onMouseMove={handleMouseMove}
      onMouseUp={handleMouseUp}
      style={{
        width: 100,
        height: 100,
        backgroundColor: isDragging ? "blue" : "gray",
        position: "absolute",
        left: position.x,
        top: position.y,
        cursor: isDragging ? "grabbing" : "grab",
      }}
    />
  );
}

Example 3: Multi-Step Wizard

function Wizard() {
  const [step, setStep] = useState(1);
  const [data, setData] = useState({});
  const [errors, setErrors] = useState({});
  const [isValidating, setIsValidating] = useState(false);

  async function handleNext(stepData) {
    // Batch 1: Start validation
    setIsValidating(true);
    setErrors({});

    const validationErrors = await validateStep(step, stepData);

    if (Object.keys(validationErrors).length > 0) {
      // Batch 2a: Show validation errors
      setErrors(validationErrors);
      setIsValidating(false);
      return;
    }

    // Batch 2b: Move to next step
    setData(prev => ({ ...prev, [`step${step}`]: stepData }));
    setStep(prev => prev + 1);
    setIsValidating(false);
    setErrors({});
    // All four updates = one re-render
  }

  function handleBack() {
    setStep(prev => prev - 1);
    setErrors({});
    // Two updates = one re-render
  }

  return (
    <div>
      <p>Step {step} of 3</p>
      {step === 1 && <Step1 onNext={handleNext} errors={errors} />}
      {step === 2 && <Step2 onNext={handleNext} onBack={handleBack} errors={errors} />}
      {step === 3 && <Step3 onNext={handleNext} onBack={handleBack} errors={errors} />}
      {isValidating && <p>Validating...</p>}
    </div>
  );
}

Example 4: Undo/Redo with History

function UndoableCounter() {
  const [history, setHistory] = useState([0]);
  const [historyIndex, setHistoryIndex] = useState(0);

  const count = history[historyIndex];
  const canUndo = historyIndex > 0;
  const canRedo = historyIndex < history.length - 1;

  function updateCount(newCount) {
    // Two state updates, one re-render
    setHistory(prev => [...prev.slice(0, historyIndex + 1), newCount]);
    setHistoryIndex(prev => prev + 1);
  }

  function undo() {
    setHistoryIndex(prev => prev - 1);
  }

  function redo() {
    setHistoryIndex(prev => prev + 1);
  }

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => updateCount(count + 1)}>+1</button>
      <button onClick={() => updateCount(count - 1)}>-1</button>
      <button onClick={undo} disabled={!canUndo}>Undo</button>
      <button onClick={redo} disabled={!canRedo}>Redo</button>
      <p>History: [{history.join(", ")}] (index: {historyIndex})</p>
    </div>
  );
}

Example 5: Demonstrating Batching vs flushSync

import { flushSync } from "react-dom";

function BatchingDemo() {
  const [log, setLog] = useState([]);
  const renderCount = useRef(0);

  renderCount.current += 1;

  function handleBatched() {
    setLog(prev => [...prev, `Batched: render #${renderCount.current}`]);
    setLog(prev => [...prev, `Batched: render #${renderCount.current}`]);
    setLog(prev => [...prev, `Batched: render #${renderCount.current}`]);
    // All three are batched -- one re-render
  }

  function handleFlushSync() {
    flushSync(() => {
      setLog(prev => [...prev, `FlushSync 1: render #${renderCount.current}`]);
    });
    // Re-render happens here

    flushSync(() => {
      setLog(prev => [...prev, `FlushSync 2: render #${renderCount.current}`]);
    });
    // Re-render happens here

    flushSync(() => {
      setLog(prev => [...prev, `FlushSync 3: render #${renderCount.current}`]);
    });
    // Re-render happens here
    // Total: 3 re-renders
  }

  return (
    <div>
      <p>Render count: {renderCount.current}</p>
      <button onClick={handleBatched}>Batched (1 render)</button>
      <button onClick={handleFlushSync}>FlushSync (3 renders)</button>
      <button onClick={() => setLog([])}>Clear Log</button>
      <ul>
        {log.map((entry, i) => <li key={i}>{entry}</li>)}
      </ul>
    </div>
  );
}

Key Takeaways

  1. Batching groups multiple setState calls into one re-render. This is a performance optimization and a correctness feature.
  2. React 18 batches everywhere -- event handlers, promises, timeouts, native events. Pre-React 18 only batched in event handlers.
  3. Batching requires createRoot. Legacy render() doesn't get automatic batching outside event handlers.
  4. await creates batch boundaries. Each synchronous block between awaits is a separate batch.
  5. flushSync opts out of batching for rare cases where you need synchronous DOM updates. Use sparingly.
  6. Direct values: last one wins. setCount(1); setCount(2); setCount(3); results in 3.
  7. Functional updates: all apply. setCount(p => p+1) three times results in +3.
  8. Batching prevents intermediate states from being visible to users.
  9. No intermediate renders means no intermediate DOM operations -- fewer reflows and repaints.
  10. You don't need to do anything to enable batching in React 18. It just works.

Explain-It Challenge

Explain batching using a postal service analogy:

  • Without batching: every letter goes in its own truck, delivered separately
  • With batching: letters are collected, one truck delivers them all at once
  • flushSync: marking a letter "URGENT" so it gets its own express delivery

Now apply this analogy to explain why setCount(count + 1) three times only increments by 1, while setCount(prev => prev + 1) three times increments by 3.


Navigation: ← 2.3.c — How React Re-renders · Next → 2.3.e — Derived State