Batching & Concurrent Features (React 18)
Batching & Concurrent Features (React 18)
LinkedIn Hook
Before React 18, state updates inside
setTimeout,fetch, and native event listeners were NOT batched. EachsetStatecaused a separate re-render. Three state updates? Three re-renders.React 18 changed everything. Automatic batching now groups ALL state updates — regardless of where they happen — into a single re-render. Timeouts, promises, native events — all batched by default.
But batching is just the beginning. React 18 introduced concurrent features that fundamentally change how rendering works:
useTransitionlets you mark updates as low priority so the UI stays responsive.useDeferredValuelets you defer expensive re-renders. AndSuspensefor data fetching gives you declarative loading states without a singleisLoadingflag.In interviews, these are the questions that separate mid-level from senior candidates. "What changed about batching in React 18?" "When would you use
useTransitionvsuseDeferredValue?" "How doesflushSyncwork?" If you cannot answer these with clarity, you are leaving senior-level offers on the table.In this lesson, I break down automatic batching,
flushSync,Suspense,useTransition, anduseDeferredValue— with real code, clear analogies, and the exact interview answers you need.Read the full lesson -> [link]
#React #JavaScript #InterviewPrep #Frontend #CodingInterview #React18 #ConcurrentReact #useTransition #Suspense #100DaysOfCode
What You'll Learn
- How automatic batching works in React 18 and what changed from React 17
- How to opt out of batching with
flushSyncand when that is necessary - How
Suspenseenables declarative data fetching with fallback UIs - How
useTransitionmarks state updates as low priority to keep the UI responsive - How
useDeferredValuedefers re-rendering of expensive computations - When to use
useTransitionvsuseDeferredValuein real applications
The Concept — Batching and Concurrency in React
Analogy: The Restaurant Kitchen
Imagine a restaurant kitchen. Orders come in from multiple tables.
Before React 18 (no universal batching): The kitchen operated under a strange rule. If orders came from the main dining room (React event handlers), the chef would batch them — cook multiple dishes at once. But if orders came from the patio or the phone (setTimeout, fetch callbacks, native events), each order was cooked individually. Three appetizers from the phone? The chef prepared one, served it, came back, prepared the next, served it, came back again. Incredibly wasteful.
React 18 (automatic batching): The kitchen upgraded. Now ALL orders are batched regardless of where they come from. Dining room, patio, phone — the chef collects all orders that arrive in the same moment, prepares them together, and serves them in one trip. Three state updates in a setTimeout? One re-render.
flushSync is the VIP customer who demands their order NOW. They bypass the batching system. The chef drops everything, cooks their dish immediately, serves it, and then returns to the batch. Use it rarely — it breaks the efficiency of batching.
useTransition is the priority system. Urgent orders (typing in a search box) get processed immediately. Low-priority orders (filtering a 10,000-row table based on that search) are marked as "when you get a chance." If a new urgent order comes in, the kitchen can abandon the low-priority work in progress and start fresh. The UI never freezes waiting for a massive re-render.
useDeferredValue is similar, but instead of controlling WHEN an update happens, it controls WHICH version of a value a component sees. The search input sees the latest value immediately, but the expensive results list keeps showing the old value until React has time to re-render with the new one.
Automatic Batching in React 18
In React 17, batching only happened inside React event handlers. In React 18 with createRoot, ALL state updates are batched — inside promises, timeouts, native event handlers, and everywhere else.
Code Example 1: Batching — Before and After React 18
import { useState } from "react";
function UserProfile() {
const [name, setName] = useState("Alice");
const [age, setAge] = useState(25);
const [loading, setLoading] = useState(false);
console.log("Component rendered");
// REACT EVENT HANDLER — batched in both React 17 and 18
const handleClick = () => {
setName("Bob");
setAge(30);
setLoading(true);
// Result: ONE re-render (batched in both versions)
};
// SETTIMEOUT — batched ONLY in React 18
const handleDelayedUpdate = () => {
setTimeout(() => {
setName("Charlie");
setAge(35);
setLoading(true);
// React 17: THREE re-renders (one per setState)
// React 18: ONE re-render (automatic batching)
}, 1000);
};
// FETCH CALLBACK — batched ONLY in React 18
const handleFetch = () => {
fetch("/api/user")
.then((res) => res.json())
.then((data) => {
setName(data.name);
setAge(data.age);
setLoading(false);
// React 17: THREE re-renders
// React 18: ONE re-render
});
};
return (
<div>
<p>{name}, {age}</p>
<button onClick={handleClick}>Sync Update</button>
<button onClick={handleDelayedUpdate}>Delayed Update</button>
<button onClick={handleFetch}>Fetch Update</button>
</div>
);
}
// Output when clicking "Delayed Update" in React 18:
// (after 1 second)
// "Component rendered" <-- printed ONCE, not three times
Key point: React 18 requires createRoot (not the legacy ReactDOM.render) for automatic batching to work. If your app still uses ReactDOM.render, you get React 17 batching behavior even on React 18.
flushSync — Opting Out of Batching
Sometimes you need a state update to be applied to the DOM immediately — for example, when you need to read a DOM measurement right after a state change. flushSync forces React to flush the update synchronously.
Code Example 2: Using flushSync to Force Immediate Updates
import { useState } from "react";
import { flushSync } from "react-dom";
function ScrollToBottom() {
const [messages, setMessages] = useState(["Hello"]);
console.log("Rendered with", messages.length, "messages");
const handleAddMessage = () => {
// Without flushSync, both updates would be batched into one render.
// But we need the DOM to update BEFORE we scroll.
flushSync(() => {
setMessages((prev) => [...prev, "New message"]);
});
// DOM is updated NOW — the new message is in the DOM
// Safe to scroll to the bottom because the DOM reflects the new message
const list = document.getElementById("message-list");
list.scrollTop = list.scrollHeight;
// This second update triggers a separate render
flushSync(() => {
setMessages((prev) => [...prev, "Another message"]);
});
// DOM is updated again — "Another message" is now in the DOM
};
return (
<ul id="message-list">
{messages.map((msg, i) => (
<li key={i}>{msg}</li>
))}
<button onClick={handleAddMessage}>Add Messages</button>
</ul>
);
}
// Output after clicking:
// "Rendered with 2 messages" <-- first flushSync triggers a render
// "Rendered with 3 messages" <-- second flushSync triggers another render
// Total: 2 re-renders instead of the 1 that batching would give
When to use flushSync: Scrolling to newly added DOM elements, reading layout measurements after state changes, integrating with third-party DOM libraries that need synchronous updates. In practice, you almost never need it.
Suspense for Data Fetching
Suspense lets you declaratively specify a loading UI while child components are waiting for asynchronous data. Instead of manually tracking isLoading state, the component "suspends" and React shows the fallback.
Code Example 3: Suspense with Lazy Loading and Data Fetching
import { Suspense, lazy, useState } from "react";
// Lazy-loaded component — triggers Suspense while loading the JS bundle
const HeavyDashboard = lazy(() => import("./HeavyDashboard"));
// Simulated data-fetching wrapper (used by frameworks like Next.js, Relay, etc.)
// The component "throws a promise" while data is loading, and Suspense catches it
function UserProfile({ userId }) {
// In a real app, this would use a Suspense-compatible data fetching library
// like React Query with suspense: true, or a framework like Next.js
const user = useSuspenseQuery(`/api/users/${userId}`);
return <h2>Welcome, {user.name}</h2>;
}
function App() {
const [showDashboard, setShowDashboard] = useState(false);
return (
<div>
{/* Suspense wraps components that may suspend */}
<Suspense fallback={<p>Loading profile...</p>}>
<UserProfile userId="123" />
</Suspense>
<button onClick={() => setShowDashboard(true)}>
Show Dashboard
</button>
{showDashboard && (
<Suspense fallback={<p>Loading dashboard...</p>}>
<HeavyDashboard />
</Suspense>
)}
</div>
);
}
// Behavior:
// 1. On initial render, "Loading profile..." is shown while UserProfile fetches data
// 2. When data arrives, UserProfile renders with the user's name
// 3. When "Show Dashboard" is clicked, "Loading dashboard..." shows while
// the HeavyDashboard JS bundle is downloaded
// 4. Once loaded, HeavyDashboard renders
// Key: No isLoading state, no ternary operators for loading — Suspense handles it
Key point: Suspense is NOT just for React.lazy. In React 18, it works with any Suspense-compatible data fetching approach. However, you should not throw promises manually — use a framework or library that integrates with Suspense (React Query, Relay, Next.js, etc.).
useTransition — Low Priority Updates
useTransition lets you mark a state update as non-urgent. React will keep the UI responsive to urgent updates (like typing) while processing the transition in the background. If a new urgent update comes in, React can interrupt the transition and restart it.
Code Example 4: useTransition for a Search Filter
import { useState, useTransition, memo } from "react";
// Expensive list component — renders 10,000 items
const FilteredList = memo(function FilteredList({ filter }) {
console.log("FilteredList rendering with filter:", filter);
// Simulate expensive filtering
const items = [];
for (let i = 0; i < 10000; i++) {
if (`Item ${i}`.toLowerCase().includes(filter.toLowerCase())) {
items.push(<li key={i}>Item {i}</li>);
}
}
return <ul>{items}</ul>;
});
function SearchPage() {
const [input, setInput] = useState("");
const [filter, setFilter] = useState("");
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
const value = e.target.value;
// URGENT: Update the input field immediately (user sees their typing)
setInput(value);
// LOW PRIORITY: Update the filter for the expensive list
startTransition(() => {
setFilter(value);
});
};
return (
<div>
<input
value={input}
onChange={handleChange}
placeholder="Search items..."
/>
{/* Show a visual hint that the list is updating */}
{isPending && <p>Updating results...</p>}
{/* This expensive re-render happens as a low-priority transition */}
<FilteredList filter={filter} />
</div>
);
}
// Behavior:
// 1. User types "abc" quickly
// 2. The input field updates instantly on each keystroke (urgent update)
// 3. FilteredList may only re-render once with "abc" (React skips intermediate
// renders for "a" and "ab" because new urgent updates interrupted them)
// 4. isPending is true while the transition is in progress
// 5. The UI NEVER freezes — typing remains smooth
Key point: useTransition does not debounce. It uses React's concurrent rendering to actually interrupt in-progress renders. This is fundamentally different from setTimeout or debounce — React can abandon partial rendering work when a higher-priority update comes in.
useDeferredValue — Deferring Expensive Re-renders
useDeferredValue accepts a value and returns a deferred version that may lag behind the original. React re-renders with the deferred value at a lower priority, keeping the UI responsive.
import { useState, useDeferredValue, memo } from "react";
const ExpensiveChart = memo(function ExpensiveChart({ data }) {
// Imagine this takes 200ms to render
console.log("Chart rendering with data length:", data.length);
return (
<div>
{data.map((point, i) => (
<div key={i} style={{ height: point, background: "#61dafb" }} />
))}
</div>
);
});
function Dashboard() {
const [range, setRange] = useState(100);
// The deferred value may lag behind the actual range value
const deferredRange = useDeferredValue(range);
// Check if the deferred value is stale (still catching up)
const isStale = range !== deferredRange;
// Generate data based on the deferred value (not the immediate value)
const data = generateChartData(deferredRange);
return (
<div>
<input
type="range"
min={10}
max={10000}
value={range}
onChange={(e) => setRange(Number(e.target.value))}
/>
<p>Range: {range}</p>
{/* Dim the chart while the deferred value is catching up */}
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<ExpensiveChart data={data} />
</div>
</div>
);
}
// Behavior:
// 1. User drags the slider rapidly
// 2. The slider and range number update instantly (urgent render)
// 3. The chart uses deferredRange, which lags behind
// 4. React re-renders the chart at low priority with the deferred value
// 5. While catching up, the chart dims to 60% opacity (visual feedback)
// 6. The slider NEVER stutters, even though the chart is expensive
useTransition vs useDeferredValue — When to Use Which
+---------------------------+-------------------------------+-------------------------------+
| Criteria | useTransition | useDeferredValue |
+---------------------------+-------------------------------+-------------------------------+
| What you control | The state UPDATE itself | A VALUE passed to a component |
+---------------------------+-------------------------------+-------------------------------+
| You own the state setter? | Yes — wrap setState in | No — you receive the value |
| | startTransition | as a prop or from a hook |
+---------------------------+-------------------------------+-------------------------------+
| Provides isPending? | Yes | No (compare old vs new value) |
+---------------------------+-------------------------------+-------------------------------+
| Typical use case | You trigger the state update | A parent passes you a prop |
| | and want to deprioritize it | and you want to defer it |
+---------------------------+-------------------------------+-------------------------------+
| Example | Search input -> filter state | Receiving a search query prop |
| | update in startTransition | and deferring it for a chart |
+---------------------------+-------------------------------+-------------------------------+
Common Mistakes
Mistake 1: Assuming Batching Requires createRoot
// WRONG: Using the legacy render API — automatic batching is NOT enabled
import ReactDOM from "react-dom";
ReactDOM.render(<App />, document.getElementById("root"));
// State updates in setTimeout, fetch, etc. are NOT batched
// CORRECT: Using createRoot — automatic batching is enabled everywhere
import { createRoot } from "react-dom/client";
const root = createRoot(document.getElementById("root"));
root.render(<App />);
// Now ALL state updates are batched regardless of context
Mistake 2: Using useTransition for Urgent Updates
function LoginForm() {
const [email, setEmail] = useState("");
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
// BUG: Wrapping the input update in startTransition makes typing laggy
// because React treats it as low priority
startTransition(() => {
setEmail(e.target.value);
});
};
return <input value={email} onChange={handleChange} />;
}
// FIX: Only wrap the EXPENSIVE update in startTransition, not the input itself.
// The input state should update urgently. A separate state for the
// expensive computation should be wrapped in startTransition.
Mistake 3: Wrapping Every State Update in startTransition
function Counter() {
const [count, setCount] = useState(0);
const [isPending, startTransition] = useTransition();
// WRONG: This update is cheap — wrapping it adds overhead for no benefit
const handleClick = () => {
startTransition(() => {
setCount((prev) => prev + 1);
});
};
return <button onClick={handleClick}>{count}</button>;
}
// FIX: Only use startTransition for updates that trigger expensive re-renders.
// Simple state updates like incrementing a counter do not need it.
// Overusing startTransition adds scheduling overhead and delays updates
// that should be instant.
Interview Questions
Q: What is automatic batching in React 18, and how does it differ from React 17?
In React 17, state updates were only batched inside React event handlers (onClick, onChange, etc.). Updates inside setTimeout, fetch callbacks, native event listeners, and promises each triggered a separate re-render. React 18 with
createRootbatches ALL state updates into a single re-render, regardless of where they originate. This means fewer re-renders and better performance by default. The key requirement is usingcreateRootinstead of the legacyReactDOM.render.
Q: What is flushSync and when would you use it?
flushSyncis a function fromreact-domthat forces React to flush state updates synchronously. It opts out of automatic batching for the updates inside its callback. You would use it when you need the DOM to reflect a state change immediately — for example, scrolling to a newly added list item, reading a DOM measurement after a state update, or integrating with third-party libraries that need synchronous DOM updates. It should be used sparingly because it defeats the performance benefits of batching.
Q: How does useTransition work and when should you use it?
useTransitionreturns[isPending, startTransition]. When you wrap a state update instartTransition, React marks it as a low-priority transition. React will process urgent updates (like user input) first and render the transition update in the background. If a new urgent update arrives while the transition is rendering, React can interrupt and discard the in-progress transition render. Use it when a state update triggers an expensive re-render (like filtering a large list or re-rendering a complex chart) and you want the UI to stay responsive to user input during that re-render.
Q: What is the difference between useTransition and useDeferredValue?
Both achieve similar goals but are used in different situations.
useTransitionwraps a state setter — you control the update itself and get anisPendingflag. Use it when you own the state and trigger the update.useDeferredValuewraps a value — you receive a value (often as a prop) and get a deferred copy that lags behind. Use it when you do not control the state update but want to defer re-rendering based on that value. To detect staleness withuseDeferredValue, compare the original and deferred values.
Q: How does Suspense work for data fetching in React 18?
Suspense lets a component "suspend" while waiting for asynchronous data. When a component suspends, React shows the nearest
Suspenseboundary's fallback UI. Once the data is ready, React re-renders the component with the actual content. This eliminates manualisLoadingstate management and ternary operators for loading states. In React 18, Suspense works with any Suspense-compatible data source — not justReact.lazy. However, you should use it through a framework or library (React Query, Relay, Next.js) rather than throwing promises manually, as the internal protocol is not considered a public API.
Quick Reference — Cheat Sheet
+-----------------------------------+-------------------------------------------+
| Concept | Key Point |
+-----------------------------------+-------------------------------------------+
| Automatic batching (React 18) | ALL state updates are batched into one |
| | re-render, regardless of context. |
| | Requires createRoot. |
+-----------------------------------+-------------------------------------------+
| flushSync | Forces synchronous DOM update. Opts out |
| | of batching. Use for DOM measurements |
| | or scroll-after-update. Use sparingly. |
+-----------------------------------+-------------------------------------------+
| Suspense | Declarative loading states. Wrap async |
| | components in <Suspense fallback={...}>. |
| | No manual isLoading flags needed. |
+-----------------------------------+-------------------------------------------+
| useTransition | Returns [isPending, startTransition]. |
| | Wrap expensive state updates to mark |
| | them as low priority. UI stays responsive.|
+-----------------------------------+-------------------------------------------+
| useDeferredValue | Returns a deferred copy of a value. |
| | The deferred version lags behind during |
| | heavy renders. Compare to detect stale. |
+-----------------------------------+-------------------------------------------+
| useTransition vs useDeferredValue | useTransition: you own the setState call. |
| | useDeferredValue: you receive the value. |
+-----------------------------------+-------------------------------------------+
| startTransition (standalone) | Same as useTransition but without |
| | isPending. Import from "react". Useful |
| | outside components (e.g., in routers). |
+-----------------------------------+-------------------------------------------+
| Concurrent rendering | React can interrupt, pause, and restart |
| | renders. Transitions are interruptible. |
| | Urgent updates are never delayed. |
+-----------------------------------+-------------------------------------------+
RULE: Use createRoot to enable automatic batching and concurrent features.
RULE: Only use startTransition for updates that trigger expensive re-renders.
RULE: useDeferredValue when you do not control the state, useTransition when you do.
RULE: flushSync is an escape hatch — not a default pattern.
Previous: Lesson 4.3 — React.memo — Preventing Unnecessary Re-renders -> Next: Lesson 5.1 — Event Handling in React ->
This is Lesson 4.4 of the React Interview Prep Course — 10 chapters, 42 lessons.