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
setStatethree 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
- What Is Batching
- Pre-React 18 Batching
- React 18 Automatic Batching
- Why Batching Improves Performance
- How Batching Works Internally
- Multiple setState Calls in One Handler
- Batching with Async/Await
- The State Update Queue
- Processing Order of Batched Updates
- flushSync: Opting Out of Batching
- Batching and Functional Updates
- Debugging Batched Updates
- Performance Impact Measurements
- Common Misconceptions
- Practical Examples
- Key Takeaways
- 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
| Context | Batched? |
|---|---|
| React event handlers (onClick, onChange) | Yes |
| Inside useEffect | Yes |
| setTimeout/setInterval callbacks | No |
| Promise .then() callbacks | No |
| After await in async functions | No |
| Native DOM event listeners | No |
| fetch/XMLHttpRequest callbacks | No |
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
- Batching groups multiple setState calls into one re-render. This is a performance optimization and a correctness feature.
- React 18 batches everywhere -- event handlers, promises, timeouts, native events. Pre-React 18 only batched in event handlers.
- Batching requires createRoot. Legacy
render()doesn't get automatic batching outside event handlers. - await creates batch boundaries. Each synchronous block between awaits is a separate batch.
- flushSync opts out of batching for rare cases where you need synchronous DOM updates. Use sparingly.
- Direct values: last one wins.
setCount(1); setCount(2); setCount(3);results in 3. - Functional updates: all apply.
setCount(p => p+1)three times results in +3. - Batching prevents intermediate states from being visible to users.
- No intermediate renders means no intermediate DOM operations -- fewer reflows and repaints.
- 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