Skip to main content

React 18 Concurrent Features

Question 1: What are useTransition and useDeferredValue? When to use each?

Answer:

useTransition and useDeferredValue are React 18 hooks that enable concurrent rendering by marking updates as non-urgent, allowing React to interrupt and prioritize more important updates. Both help improve perceived performance by keeping the UI responsive during expensive operations.

useTransition provides a way to mark state updates as transitions, which are interruptible and can be deprioritized. It returns a startTransition function and an isPending boolean. Use it when you control the state update directly and want to show loading states while the transition is pending.

const [isPending, startTransition] = useTransition();

startTransition(() => {
setSearchQuery(input); // Non-urgent update
});

useDeferredValue accepts a value and returns a deferred version that may lag behind the original. It's ideal when you don't control the state update (like props from parent) or want to defer rendering of expensive components without changing the source.

const deferredQuery = useDeferredValue(searchQuery);

Key Decision Matrix:

  • Use useTransition when: You own the state update, need isPending indicator, want explicit transition boundaries
  • Use useDeferredValue when: Working with props, want to defer component rendering, simpler API without loading states

Both leverage React's concurrent features to keep high-priority updates (like typing) fast while deferring low-priority updates (like filtering large lists). The main difference is control: useTransition wraps the state setter, while useDeferredValue wraps the value itself.

Common Use Cases:

  • Search/filter interfaces with large datasets
  • Tab switching with lazy loading
  • Complex visualizations that update on input
  • Debouncing without explicit timers

<details> <summary><strong>🔍 Deep Dive: Transition Internals and Priority Lanes</strong></summary>

Transition Internals and Priority Lanes:

React 18 introduces a sophisticated priority lane system that categorizes updates into different urgency levels. Understanding these lanes is crucial to mastering concurrent features.

Priority Lanes Hierarchy:

  1. SyncLane (Priority: 1) - Synchronous updates like user input (typing, clicking)
  2. InputContinuousLane (Priority: 2) - Continuous input like dragging, scrolling
  3. DefaultLane (Priority: 16) - Normal updates like data fetching responses
  4. TransitionLane (Priority: 64-128) - Transition updates marked with startTransition
  5. IdleLane (Priority: Infinity) - Lowest priority, runs when nothing else is happening

useTransition Mechanics:

When you call startTransition, React internally:

// Simplified internal React logic
function startTransition(callback) {
const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = 1; // Mark as transition

try {
setIsPending(true);
callback(); // Execute state updates with TransitionLane priority
} finally {
ReactCurrentBatchConfig.transition = prevTransition;
setIsPending(false);
}
}

What happens under the hood:

  1. Lane Assignment: Updates inside startTransition are tagged with TransitionLane (priority 64+)
  2. Interruptibility: React can pause rendering of transition updates if higher-priority updates arrive
  3. Work Scheduling: The scheduler uses a min-heap to process lanes by priority
  4. Rendering Phases:
    • Render phase: Can be interrupted and restarted
    • Commit phase: Synchronous, cannot be interrupted
  5. isPending State: Automatically true during transition, false when committed

useDeferredValue Mechanics:

useDeferredValue works differently by creating a separate render pass:

// Simplified internal logic
function useDeferredValue(value) {
const [deferredValue, setDeferredValue] = useState(value);

useEffect(() => {
startTransition(() => {
setDeferredValue(value); // Update deferred value with low priority
});
}, [value]);

return deferredValue;
}

Key behaviors:

  1. Initial Render: Returns the original value immediately
  2. Subsequent Updates: Returns the old value while new value renders in background
  3. Stale Value Strategy: Component renders twice - once with old value (fast), once with new value (deferred)
  4. Automatic Memoization: React memoizes components using deferred values to prevent unnecessary re-renders

Time Slicing and Work Loop:

React's concurrent renderer breaks rendering work into small units (typically 5ms chunks):

// Conceptual work loop
function workLoop(deadline) {
while (workInProgress && deadline.timeRemaining() > 0) {
workInProgress = performUnitOfWork(workInProgress);
}

if (workInProgress) {
// More work to do, schedule continuation
scheduleCallback(workLoop);
} else {
// Work complete, commit changes
commitRoot();
}
}

Priority Preemption Example:

// Scenario: User types while transition is rendering
startTransition(() => {
setFilteredItems(expensiveFilter(items)); // TransitionLane
});

// User types during above transition
setSearchInput('new query'); // SyncLane

// React behavior:
// 1. Detects higher priority update (SyncLane > TransitionLane)
// 2. Pauses transition rendering
// 3. Processes search input immediately
// 4. Updates UI with new input
// 5. Resumes transition rendering from scratch

Memory and Performance Implications:

  1. Double Rendering: Deferred values cause components to render twice, increasing work
  2. Memory Overhead: React keeps multiple versions of fiber tree during concurrent rendering
  3. Bailout Optimization: React uses Object.is comparison to skip unnecessary deferred updates
  4. Lane Pooling: TransitionLanes are pooled and reused to prevent memory leaks

Advanced Pattern - Combining Both Hooks:

function SmartSearch({ items }) {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const deferredQuery = useDeferredValue(query);

const handleChange = (e) => {
setQuery(e.target.value); // Urgent: Update input immediately

startTransition(() => {
// Deferred: Update results with isPending indicator
setFilteredResults(filterItems(items, e.target.value));
});
};

// Use deferredQuery for expensive child rendering
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<ResultsList query={deferredQuery} items={items} />
</>
);
}

This combination provides:

  • Instant input feedback (query updates synchronously)
  • Loading indicator (isPending for UX)
  • Deferred expensive rendering (deferredQuery)

<details> <summary><strong>🐛 Real-World Scenario: Search Optimization with useTransition</strong></summary>

Production Issue: Search Interface with 10,000+ Products

Context: E-commerce platform with a product search interface. Users reported that typing felt sluggish, with visible lag between keystrokes. The search bar would freeze for 200-300ms on each keystroke when filtering through 10,000+ products.

Initial Implementation (Problematic):

// ❌ Synchronous filtering blocks UI thread
function ProductSearch() {
const [query, setQuery] = useState('');
const products = useProducts(); // 10,000+ items

// Expensive filter runs on every keystroke
const filteredProducts = useMemo(() => {
return products.filter(product =>
product.name.toLowerCase().includes(query.toLowerCase()) ||
product.description.toLowerCase().includes(query.toLowerCase()) ||
product.tags.some(tag => tag.includes(query.toLowerCase()))
);
}, [products, query]);

return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)} // Blocks for 200-300ms
placeholder="Search products..."
/>
<ProductGrid products={filteredProducts} /> {/* 100ms+ render time */}
</div>
);
}

Performance Metrics (Before):

  • Input lag: 250ms average per keystroke
  • Time to Interactive (TTI): 850ms after typing stops
  • FPS during typing: 15-20 FPS (janky)
  • User complaints: 127 tickets in 2 weeks
  • Bounce rate: Increased 18% on search page

Debugging Process:

  1. React DevTools Profiler: Identified ProductGrid taking 180ms to render
  2. Chrome Performance Tab: Main thread blocked during filtering (220ms)
  3. Flame Graph Analysis: toLowerCase() called 30,000+ times per keystroke
  4. User Timing API: Measured actual input latency
// Measurement code
performance.mark('input-start');
setQuery(value);
requestAnimationFrame(() => {
performance.mark('input-end');
performance.measure('input-lag', 'input-start', 'input-end');
const measure = performance.getEntriesByName('input-lag')[0];
console.log('Input lag:', measure.duration); // 250ms!
});

Solution 1: Using useTransition (Best for Controlled State):

// ✅ Transition-based solution with isPending indicator
function ProductSearch() {
const [query, setQuery] = useState('');
const [filteredProducts, setFilteredProducts] = useState([]);
const [isPending, startTransition] = useTransition();
const products = useProducts();

const handleSearch = (value) => {
// Urgent: Update input immediately (SyncLane)
setQuery(value);

// Deferred: Filter and render results (TransitionLane)
startTransition(() => {
const filtered = products.filter(product =>
product.name.toLowerCase().includes(value.toLowerCase()) ||
product.description.toLowerCase().includes(value.toLowerCase()) ||
product.tags.some(tag => tag.includes(value.toLowerCase()))
);
setFilteredProducts(filtered);
});
};

return (
<div>
<input
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search products..."
/>

{/* Show loading state during transition */}
{isPending && (
<div className="search-pending">
<Spinner size="small" />
<span>Searching {products.length} products...</span>
</div>
)}

<ProductGrid
products={filteredProducts}
opacity={isPending ? 0.6 : 1} // Visual feedback
/>
</div>
);
}

Solution 2: Using useDeferredValue (Best for Simpler Cases):

// ✅ Deferred value solution (less code, no isPending)
function ProductSearch() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const products = useProducts();

// Use deferred value for expensive computation
const filteredProducts = useMemo(() => {
return products.filter(product =>
product.name.toLowerCase().includes(deferredQuery.toLowerCase()) ||
product.description.toLowerCase().includes(deferredQuery.toLowerCase()) ||
product.tags.some(tag => tag.includes(deferredQuery.toLowerCase()))
);
}, [products, deferredQuery]);

// Show stale indicator when values differ
const isStale = query !== deferredQuery;

return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)} // Always fast
placeholder="Search products..."
/>

{isStale && <span className="stale-indicator">Updating...</span>}

<ProductGrid
products={filteredProducts}
opacity={isStale ? 0.6 : 1}
/>
</div>
);
}

Performance Metrics (After):

With useTransition:

  • Input lag: 12ms average (95% improvement)
  • Time to Interactive: 450ms (47% improvement)
  • FPS during typing: 58-60 FPS (smooth)
  • isPending duration: 180ms average (acceptable background work)
  • User satisfaction: Complaints dropped to 8 tickets/2 weeks (94% reduction)

With useDeferredValue:

  • Input lag: 15ms average (94% improvement)
  • Time to Interactive: 480ms (44% improvement)
  • FPS during typing: 57-60 FPS
  • Deferred lag: 200ms average
  • Code simplicity: 30% less code than useTransition version

Additional Optimization - Web Worker Integration:

For even better performance, combine with Web Workers:

// ✅ Ultimate solution: Transition + Web Worker
const searchWorker = new Worker('/workers/search-worker.js');

function ProductSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();

const handleSearch = (value) => {
setQuery(value);

startTransition(() => {
// Offload filtering to Web Worker
searchWorker.postMessage({ query: value, products });

searchWorker.onmessage = (e) => {
setResults(e.data.filtered);
};
});
};

// Result: 5ms input lag, 60 FPS, zero main thread blocking
}

Business Impact:

  • Bounce rate decreased 22% on search page
  • Average session duration increased 34%
  • Conversion rate improved 8% for search-originated purchases
  • Mobile performance improved even more (40% faster on mid-range devices)

Key Learnings:

  1. Measure first: Use Profiler and Performance API before optimizing
  2. Choose the right hook: useTransition for loading states, useDeferredValue for simplicity
  3. Progressive enhancement: Start with concurrent features, add Web Workers if needed
  4. User perception matters: 15ms input lag feels instant, 200ms feels sluggish
  5. Monitor real users: RUM (Real User Monitoring) data revealed the issue

</details>


<details> <summary><strong>⚖️ Trade-offs: useTransition vs useDeferredValue</strong></summary>

useTransition vs useDeferredValue: Decision Matrix

When to Use useTransition:

Pros:

  • Explicit control: You decide exactly what updates are transitions
  • isPending indicator: Built-in loading state for better UX
  • Granular transitions: Can mark specific state updates as low priority
  • Multiple state updates: Can batch multiple setStates in one transition
  • Better for complex flows: Navigation, tab switching, multi-step forms

Cons:

  • More verbose: Requires wrapping state updates in startTransition
  • State ownership required: Only works if you control the state setter
  • Manual state management: Need to manage isPending in UI
  • Cognitive overhead: Developers must understand transition boundaries

Best use cases:

// Navigation with data fetching
const [page, setPage] = useState('home');
const [isPending, startTransition] = useTransition();

const navigate = (newPage) => {
startTransition(() => {
setPage(newPage);
fetchPageData(newPage); // Multiple updates in transition
});
};

// Tab switching with loading state
function Tabs() {
const [tab, setTab] = useState('overview');
const [isPending, startTransition] = useTransition();

return (
<>
<TabButtons
activeTab={tab}
onChange={(t) => startTransition(() => setTab(t))}
/>
{isPending && <TabLoadingBar />} {/* isPending is crucial here */}
<TabContent tab={tab} />
</>
);
}

When to Use useDeferredValue:

Pros:

  • Simpler API: Just wrap the value, no callback needed
  • Works with props: Can defer values from parent components
  • Less boilerplate: No need to manage startTransition calls
  • Automatic optimization: React handles memoization internally
  • Good for component libraries: Defer rendering without changing parent

Cons:

  • No loading indicator: No built-in isPending state
  • Less control: Can't mark specific updates as transitions
  • Double rendering: Component renders with both old and new values
  • Stale value complexity: Need manual comparison to detect staleness
  • Memory overhead: React keeps both old and new values in memory

Best use cases:

// Deferring expensive child components
function Dashboard({ filters }) {
const deferredFilters = useDeferredValue(filters);

return (
<>
<FilterControls filters={filters} /> {/* Fast controls */}
<ExpensiveChart filters={deferredFilters} /> {/* Deferred render */}
</>
);
}

// Search with simpler code (no isPending needed)
function SimpleSearch() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const results = useSearchResults(deferredQuery);

return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Results data={results} />
</>
);
}

Performance Comparison:

MetricuseTransitionuseDeferredValue
Input responsivenessExcellent (direct control)Excellent (automatic)
Code complexityHigher (explicit transitions)Lower (just wrap value)
Loading statesBuilt-in (isPending)Manual (compare values)
Memory usageLower (single render pass)Higher (double render)
Render countOptimal+1 extra render per update
Bundle size+0.5kb+0.3kb
Debugging easeEasier (explicit boundaries)Harder (implicit deferral)

Concurrent Features vs Traditional Debouncing:

Traditional Debouncing:

// ❌ Debouncing delays ALL updates, including UI feedback
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);

// Problem: Input lags by 300ms before ANY update happens

Pros of debouncing:

  • Reduces number of expensive operations
  • Simple mental model
  • Works in any React version

Cons of debouncing:

  • Delays user feedback (feels unresponsive)
  • Fixed delay (not adaptive to device performance)
  • Still blocks main thread when delay ends
  • No interruptibility

Concurrent Features (useTransition/useDeferredValue):

// ✅ Instant UI feedback, deferred expensive work
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);

// Benefit: Input updates immediately, filtering deferred

Pros of concurrent features:

  • Instant UI feedback (no artificial delay)
  • Interruptible (adapts to user actions)
  • Automatic prioritization
  • Better perceived performance

Cons of concurrent features:

  • Requires React 18+
  • More complex mental model
  • May trigger more renders
  • Not suitable for all scenarios (API calls still need debouncing)

When to Combine Both:

// ✅ Best of both worlds: Debounce API calls, defer rendering
function SmartSearch() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300); // Debounce API calls
const deferredQuery = useDeferredValue(query); // Defer local filtering

useEffect(() => {
fetchResults(debouncedQuery); // API call debounced
}, [debouncedQuery]);

const localResults = useMemo(() => {
return filterLocalCache(deferredQuery); // Local filter deferred
}, [deferredQuery]);

return (/* ... */);
}

Accessibility Considerations:

useTransition with announcements:

function AccessibleTransition() {
const [isPending, startTransition] = useTransition();
const [announcement, setAnnouncement] = useState('');

const handleUpdate = () => {
startTransition(() => {
updateData();
setAnnouncement('Loading new content');
});
};

useEffect(() => {
if (!isPending && announcement) {
setAnnouncement('Content loaded');
}
}, [isPending]);

return (
<>
<button onClick={handleUpdate}>Update</button>
<div role="status" aria-live="polite">{announcement}</div>
</>
);
}

Key Trade-off Summary:

  • useTransition: Choose when you need explicit control and loading states
  • useDeferredValue: Choose when you want simplicity and work with props
  • Debouncing: Still needed for API calls and rate limiting
  • Combination: Often the best approach for complex UIs
  • Progressive enhancement: Start with simpler solution, add complexity only if needed

</details>


<details> <summary><strong>💬 Explain to Junior: Making React 18 Apps Feel Instant</strong></summary>

The Coffee Shop Analogy:

Imagine you're a barista at a busy coffee shop. Customers are ordering drinks (user input), but making each drink takes time (rendering updates).

Without Concurrent Features (Old React): When a customer orders, you stop everything and make their entire drink before taking the next order. If someone wants a simple question answered while you're making a complex drink, they have to wait. This is like old React blocking the UI thread.

With useTransition: You take orders immediately (urgent updates) but mark complex drink orders as "transitions" that can be paused. If someone asks a quick question while you're making a cappuccino, you pause the cappuccino, answer them, then resume. You also tell waiting customers "I'm working on it" (isPending indicator).

// Coffee shop as code
const [order, setOrder] = useState('');
const [isPending, startTransition] = useTransition();

const takeOrder = (drink) => {
setOrder(drink); // Take order immediately (fast)

startTransition(() => {
makeDrink(drink); // Make drink in background (slow)
});
};

// Result: Customers always get acknowledged instantly

With useDeferredValue: Similar to useTransition, but you keep serving the "old drink" while making the new one. Once the new drink is ready, you swap it out. Customers don't see you working, they just eventually get the new drink.

// Coffee shop deferred serving
const [orderRequest, setOrderRequest] = useState('latte');
const currentDrink = useDeferredValue(orderRequest);

// Customers see old drink (latte) while new drink (cappuccino) is being made
// When new drink ready, swap it out

Simple Explanation for Interviews:

"useTransition and useDeferredValue are React 18 hooks that keep the UI responsive during expensive operations. Think of them as a way to tell React 'this update is not urgent, prioritize user input instead.'

useTransition wraps state updates and gives you an isPending flag to show loading states. It's like saying 'I'm about to do something slow, give me a way to tell users I'm working on it.'

useDeferredValue wraps a value and returns a lagged version. It's simpler but doesn't give you a loading indicator. It's like saying 'keep showing the old result while computing the new one.'

The key benefit is that typing and clicking stay smooth even when rendering heavy components. React can interrupt low-priority work if the user does something more important."

When to Use Each (Simple Rules):

Use useTransition if:

  • You own the state (you're calling setState)
  • You want a loading spinner or indicator
  • You're doing navigation or tab switching
  • Example: "When user clicks tab, show loading bar while fetching data"

Use useDeferredValue if:

  • You're receiving the value as props
  • You don't need loading indicators
  • You want simpler code
  • Example: "Parent sends search query, defer rendering large results list"

Code Example - Search Filter (Most Common Interview Question):

// ❌ Bad: Typing feels slow
function SearchBad() {
const [query, setQuery] = useState('');
const items = useItems(); // 10,000 items

const filtered = items.filter(item =>
item.name.includes(query)
); // Blocks UI for 200ms!

return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<List items={filtered} />
</>
);
}

// ✅ Good: With useTransition
function SearchWithTransition() {
const [query, setQuery] = useState('');
const [filtered, setFiltered] = useState([]);
const [isPending, startTransition] = useTransition();
const items = useItems();

const handleChange = (value) => {
setQuery(value); // Fast: Update input immediately

startTransition(() => {
// Slow: Filter in background
setFiltered(items.filter(item => item.name.includes(value)));
});
};

return (
<>
<input value={query} onChange={(e) => handleChange(e.target.value)} />
{isPending && <Spinner />} {/* Show loading state */}
<List items={filtered} />
</>
);
}

// ✅ Good: With useDeferredValue (simpler)
function SearchWithDeferred() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const items = useItems();

const filtered = useMemo(() =>
items.filter(item => item.name.includes(deferredQuery)),
[items, deferredQuery]
);

return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<List items={filtered} />
</>
);
}

Interview Answer Template:

"In React 18, useTransition and useDeferredValue help keep the UI responsive during expensive operations by marking updates as low priority.

I'd use useTransition when I control the state update and need a loading indicator. For example, in a search interface, I'd update the input immediately but wrap the filtering logic in startTransition so typing stays smooth. The isPending flag lets me show a spinner while results are being computed.

I'd use useDeferredValue when I receive a value as props and want simpler code. It automatically creates a lagged version of the value, so React renders with the old value while computing the new one in the background.

Both hooks use React's concurrent features to interrupt low-priority work when high-priority updates arrive, like user input. This makes the app feel faster even though the actual work takes the same time."

Common Mistakes to Avoid:

// ❌ Mistake 1: Using transition for everything
startTransition(() => {
setCount(count + 1); // Don't defer simple updates!
});

// ✅ Only use for expensive operations
startTransition(() => {
setFilteredList(expensiveFilter(items)); // Good use
});

// ❌ Mistake 2: Forgetting useMemo with useDeferredValue
const deferredQuery = useDeferredValue(query);
const results = expensiveFilter(items, deferredQuery); // Recalculates every render!

// ✅ Wrap in useMemo
const results = useMemo(() =>
expensiveFilter(items, deferredQuery),
[items, deferredQuery]
);

// ❌ Mistake 3: Not showing loading state with useTransition
const [isPending, startTransition] = useTransition();
// ... but never use isPending in UI

// ✅ Use isPending for UX
{isPending && <LoadingSpinner />}

Quick Comparison Table:

FeatureuseTransitionuseDeferredValue
ComplexityMore codeLess code
Loading stateYes (isPending)No (manual)
State controlNeed setStateWorks with props
Best forTransitions, navigationDeferred rendering
Interview frequencyHighMedium

Remember: Both hooks are about perception, not actual speed. The expensive work still happens, but the UI stays responsive, making the app feel faster.


Question 2: How does automatic batching work in React 18?

Answer:

Automatic batching is a React 18 feature that automatically groups multiple state updates into a single re-render, regardless of where the updates occur. Before React 18, batching only worked in React event handlers. Now it works everywhere: timeouts, promises, native event handlers, and any other event.

The Problem Before React 18:

// React 17 - Multiple renders in async code
function handleClick() {
setCount(c => c + 1); // Batched
setFlag(f => !f); // Batched together
// ✅ Only 1 render
}

setTimeout(() => {
setCount(c => c + 1); // Render 1
setFlag(f => !f); // Render 2
// ❌ 2 separate renders
}, 100);

The Solution in React 18:

// React 18 - Automatic batching everywhere
function handleClick() {
setCount(c => c + 1); // Batched
setFlag(f => !f); // Batched
// ✅ 1 render
}

setTimeout(() => {
setCount(c => c + 1); // Batched
setFlag(f => !f); // Batched
// ✅ Now only 1 render!
}, 100);

fetch('/api/data').then(() => {
setData(newData); // Batched
setLoading(false); // Batched
// ✅ 1 render in promises too!
});

How It Works:

React 18 uses a new root API (createRoot) that enables automatic batching. When you schedule state updates, React queues them in a batch queue and flushes them together in a single render pass. This happens automatically for all updates, including those in:

  • setTimeout/setInterval callbacks
  • Promise .then() callbacks
  • Native event handlers (addEventListener)
  • Async/await functions
  • Any other asynchronous code

Opting Out with flushSync:

If you explicitly need synchronous updates (rare), use flushSync:

import { flushSync } from 'react-dom';

flushSync(() => {
setCount(c => c + 1); // Renders immediately
});
setFlag(f => !f); // Renders separately

Benefits:

  • Better performance (fewer renders)
  • More consistent behavior across all contexts
  • Simpler mental model (batching works everywhere)
  • No need to manually batch with unstable_batchedUpdates

Automatic batching is enabled by default in React 18 and requires no code changes for most apps. It's one of the most impactful performance improvements in React 18.


<details> <summary><strong>🔍 Deep Dive: Event Batching and React Scheduler</strong></summary>

Batching Algorithm and Internal Implementation:

React 18's automatic batching is powered by a complete rewrite of the rendering pipeline called the Concurrent Renderer. Understanding the internals reveals how React achieves consistent batching across all contexts.

Core Concepts:

  1. Execution Context: React tracks whether it's currently in a "batch update" context
  2. Update Queue: State updates are added to a queue rather than executed immediately
  3. Flush Mechanism: Queued updates are "flushed" (processed) together at strategic points
  4. Lane-Based Priority: Each update is assigned a priority lane for scheduling

The Batching Queue Structure:

// Simplified internal React structure
const batchQueue = {
updates: [], // Array of pending state updates
isBatching: false, // Whether currently in batch mode
scheduledFlush: null, // Timeout/microtask for flushing
};

// When setState is called
function setState(newState) {
const update = {
state: newState,
component: currentComponent,
priority: getCurrentPriority(),
};

batchQueue.updates.push(update);

if (!batchQueue.scheduledFlush) {
// Schedule flush in next microtask (automatic batching)
batchQueue.scheduledFlush = queueMicrotask(() => {
flushBatchedUpdates();
});
}
}

// Flush all queued updates in one render pass
function flushBatchedUpdates() {
const updates = batchQueue.updates;
batchQueue.updates = [];
batchQueue.scheduledFlush = null;

// Group updates by component
const componentUpdates = groupByComponent(updates);

// Render each component once with all updates
for (const [component, componentUpdates] of componentUpdates) {
applyUpdates(component, componentUpdates);
renderComponent(component); // Single render per component
}
}

React 17 vs React 18 Execution Flow:

React 17 (Legacy Root):

// React 17 behavior
document.addEventListener('click', () => {
setCount(1); // Not in React context
setName('A'); // Each setState triggers immediate render
});
// Result: 2 renders

// React event handler
function onClick() {
setCount(1); // In React context (batched)
setName('A'); // Batched together
}
// Result: 1 render

React 18 (Concurrent Root):

// React 18 with createRoot
document.addEventListener('click', () => {
setCount(1); // Automatically batched
setName('A'); // Batched with above
});
// Result: 1 render (NEW!)

// React event handler (same as before)
function onClick() {
setCount(1); // Batched
setName('A'); // Batched
}
// Result: 1 render

Microtask vs Macrotask Batching:

React 18 uses microtasks for batching, which execute before the browser paints:

// Event loop order:
// 1. Execute current task (JavaScript)
// 2. Execute all microtasks (Promises, queueMicrotask)
// 3. Browser paint/render
// 4. Execute next macrotask (setTimeout, setInterval)

function handleClick() {
setCount(1);
setName('A');

// React schedules microtask to flush batched updates
queueMicrotask(() => {
flushBatchedUpdates(); // Happens before browser paint
});

// User sees only final result (1 paint)
}

Priority Lanes and Batching:

Batching respects priority lanes. Updates with the same priority are batched together:

// Simplified lane-based batching
function batchUpdatesByLane(updates) {
const lanes = {
SyncLane: [], // Urgent updates (user input)
DefaultLane: [], // Normal updates
TransitionLane: [], // Deferred updates
};

// Group by priority
updates.forEach(update => {
lanes[update.lane].push(update);
});

// Process each lane separately
Object.values(lanes).forEach(laneUpdates => {
if (laneUpdates.length > 0) {
processUpdates(laneUpdates); // Batched within lane
}
});
}

// Example: Mixed priority updates
function handleAction() {
setInputValue(value); // SyncLane (urgent)
startTransition(() => {
setFilteredList(filtered); // TransitionLane (deferred)
});

// React processes:
// 1. Batch and render SyncLane updates immediately
// 2. Batch and render TransitionLane updates later
}

Batching Across Component Boundaries:

Automatic batching works across multiple components:

// Parent and child updates batched together
function Parent() {
const [parentState, setParentState] = useState(0);

return <Child onUpdate={() => setParentState(1)} />;
}

function Child({ onUpdate }) {
const [childState, setChildState] = useState(0);

const handleClick = () => {
setChildState(1); // Child update
onUpdate(); // Parent update
// React 18: Both batched = 1 render for Parent + Child
// React 17: 2 separate renders
};

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

Edge Case: Async Functions and Batching:

// Async/await with automatic batching
async function fetchData() {
setLoading(true); // Update 1

const data = await fetch('/api');

setData(data); // Update 2
setLoading(false); // Update 3
// React 18: Updates 2 and 3 batched (after await)
// React 17: 2 separate renders
}

Why This Works:

After await, the continuation runs in a new microtask. React 18 tracks the batching context across microtasks:

// Internal React tracking
let isBatchingUpdates = false;

function trackBatching(callback) {
const prevBatching = isBatchingUpdates;
isBatchingUpdates = true;

try {
callback();
} finally {
if (!prevBatching) {
isBatchingUpdates = false;
flushBatchedUpdates();
}
}
}

// Async continuation maintains batching context
async function example() {
trackBatching(() => {
setState1(); // Batched
});

await something();

trackBatching(() => {
setState2(); // Batched
setState3(); // Batched with setState2
});
}

flushSync Deep Dive:

flushSync forces synchronous rendering, bypassing batching:

import { flushSync } from 'react-dom';

function handleClick() {
flushSync(() => {
setCount(c => c + 1); // Renders immediately (synchronous)
});

console.log(ref.current.textContent); // Reads updated DOM

setName('New Name'); // Separate render (not batched with above)
}

// Use cases for flushSync:
// 1. Reading layout information immediately after update
// 2. Integrating with third-party libraries that expect sync DOM
// 3. Ensuring specific render order (rare)

Performance Implications:

Memory:

  • Batch queue adds minimal overhead (~100 bytes per update)
  • Queue is cleared after each flush

CPU:

  • Batching reduces reconciliation work (fewer render passes)
  • Example: 5 state updates in timeout:
    • React 17: 5 × reconciliation time
    • React 18: 1 × reconciliation time (80% reduction)

Browser Paint:

  • Fewer renders = fewer paints = smoother UI
  • Batching prevents "flashing" from intermediate states

Microtask Queue Saturation:

  • Thousands of rapid updates can saturate microtask queue
  • React uses priority scheduling to prevent starvation
  • High-priority updates processed first, even in large batches

Automatic Batching with Suspense:

Batching interacts with Suspense boundaries:

// Updates batched across Suspense boundaries
function Component() {
const [state1, setState1] = useState(0);

return (
<Suspense fallback={<Loading />}>
<ChildWithUpdate
onUpdate={() => {
setState1(1); // Parent update
setChildState(1); // Child update (inside Suspense)
// Batched together, single render
}}
/>
</Suspense>
);
}

Batching is a Fundamental Concurrent Feature:

Automatic batching is enabled by the concurrent renderer's ability to pause and resume work. The batching mechanism leverages the same scheduling infrastructure that powers transitions and Suspense.

</details>


<details> <summary><strong>🐛 Real-World Scenario: Form Submission with Multiple State Updates</strong></summary>

Production Issue: Form Submission with Multiple State Updates

Context: A SaaS dashboard application had a complex form submission flow that updated multiple pieces of state: validation status, loading states, success messages, analytics tracking, and UI state. Users reported that forms felt sluggish on form submission, especially on slower devices.

Initial Implementation (React 17 with Legacy Root):

// ❌ React 17: Multiple re-renders on form submit
function ContactForm() {
const [formData, setFormData] = useState({});
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState(null);

const handleSubmit = async (e) => {
e.preventDefault();

// Validate
const validationErrors = validateForm(formData);
setErrors(validationErrors); // Render 1

if (Object.keys(validationErrors).length > 0) return;

setIsSubmitting(true); // Render 2

try {
const response = await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify(formData),
});

const data = await response.json();

// After await, each setState causes a render in React 17
setIsSubmitting(false); // Render 3
setSubmitStatus('success'); // Render 4
setFormData({}); // Render 5
setErrors({}); // Render 6

trackAnalytics('form_submit', data); // More renders from analytics

// Total: 6+ renders in quick succession
} catch (error) {
setIsSubmitting(false); // Render N
setSubmitStatus('error'); // Render N+1
setErrors({ api: error.message }); // Render N+2
}
};

return (/* form JSX */);
}

Performance Metrics (Before - React 17):

Using Chrome DevTools and React Profiler:

  • Total renders on submit: 6 renders
  • Time to final render: 450ms
  • JavaScript execution time: 180ms
  • Main thread blocking: 220ms
  • Frames dropped: 12 frames (200ms at 60fps)
  • User perception: Visible flashing of UI states
  • Lighthouse Performance Score: 72/100
  • Real User Monitoring (RUM): 28% of users experienced jank

Debugging Process:

// Added performance measurement
function handleSubmit(e) {
performance.mark('submit-start');
let renderCount = 0;

// Monkey-patch setState to count renders
const originalSetState = useState()[1];
const countingSetState = (...args) => {
renderCount++;
console.log(`Render ${renderCount}`);
originalSetState(...args);
};

// ... form submission logic

requestIdleCallback(() => {
performance.mark('submit-end');
performance.measure('submit-flow', 'submit-start', 'submit-end');
const measure = performance.getEntriesByName('submit-flow')[0];
console.log(`Total time: ${measure.duration}ms`);
console.log(`Total renders: ${renderCount}`);
});
}

// Output:
// Render 1 (setErrors)
// Render 2 (setIsSubmitting true)
// Render 3 (setIsSubmitting false)
// Render 4 (setSubmitStatus)
// Render 5 (setFormData)
// Render 6 (setErrors)
// Total time: 450ms
// Total renders: 6

React Profiler Flamegraph: Each render showed full component tree reconciliation, with expensive child components re-rendering unnecessarily.

Attempted Solution 1: Manual Batching (React 17):

import { unstable_batchedUpdates } from 'react-dom';

// Partial improvement
const handleSubmit = async (e) => {
e.preventDefault();

unstable_batchedUpdates(() => {
setErrors(validationErrors);
setIsSubmitting(true);
}); // Batched: 1 render

const response = await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify(formData),
});

// ❌ Problem: Can't batch across async boundary
// These still cause separate renders in React 17
setIsSubmitting(false); // Render 1
setSubmitStatus('success'); // Render 2
setFormData({}); // Render 3
setErrors({}); // Render 4
};

// Result: Reduced from 6 to 5 renders (minor improvement)

Solution: Upgrade to React 18 with Automatic Batching:

// ✅ React 18: Automatic batching everywhere
import { createRoot } from 'react-dom/client';

// 1. Upgrade root
const container = document.getElementById('root');
const root = createRoot(container);
root.render(<App />);

// 2. No code changes needed in component!
function ContactForm() {
const [formData, setFormData] = useState({});
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState(null);

const handleSubmit = async (e) => {
e.preventDefault();

// Validation (batched)
const validationErrors = validateForm(formData);
setErrors(validationErrors);
if (Object.keys(validationErrors).length > 0) return;

setIsSubmitting(true); // Batched with setErrors

try {
const response = await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify(formData),
});

// ✅ React 18: All updates after await are automatically batched!
setIsSubmitting(false); // Batched
setSubmitStatus('success'); // Batched
setFormData({}); // Batched
setErrors({}); // Batched
// Total: 1 render for all 4 updates!

trackAnalytics('form_submit'); // Even analytics updates batched
} catch (error) {
setIsSubmitting(false); // Batched
setSubmitStatus('error'); // Batched
setErrors({ api: error.message }); // Batched
// Total: 1 render for all 3 updates
}
};

return (/* same JSX, no changes */);
}

Performance Metrics (After - React 18):

  • Total renders on submit: 2 renders (validation + success)
  • Time to final render: 180ms (60% improvement)
  • JavaScript execution time: 95ms (47% reduction)
  • Main thread blocking: 85ms (61% reduction)
  • Frames dropped: 0 frames (perfectly smooth)
  • User perception: No visible intermediate states
  • Lighthouse Performance Score: 94/100 (+22 points)
  • Real User Monitoring: <2% jank reports (93% improvement)

Visual Comparison:

React 17 Timeline:
[Submit] → [Render] → [Render] → [await] → [Render] → [Render] → [Render] → [Render] → [Done]
0ms 50ms 100ms 200ms 250ms 300ms 350ms 400ms 450ms

React 18 Timeline:
[Submit] → [Render] → [await] → [Render] → [Done]
0ms 50ms 150ms 180ms 180ms

Additional Optimization: Strategic flushSync for DOM Measurements:

// Advanced case: Need to measure DOM after specific update
import { flushSync } from 'react-dom';

function FormWithAnimation() {
const formRef = useRef();

const handleSubmit = async (e) => {
e.preventDefault();

// Update loading state and measure DOM immediately
flushSync(() => {
setIsSubmitting(true);
});

// Read DOM dimensions for animation
const height = formRef.current.offsetHeight;
animateFormCollapse(height);

const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(formData),
});

// These still batch automatically (React 18)
setIsSubmitting(false);
setSubmitStatus('success');
setFormData({});
};

return <form ref={formRef}>{/* ... */}</form>;
}

Real-World Impact on Complex Dashboard:

The application had 50+ forms across different pages. After upgrading to React 18:

Before (React 17):

  • Average form submission: 6-8 renders
  • Total JavaScript time: 350-500ms per form
  • User complaints: 45 tickets about "slow forms"
  • Mobile performance: Especially poor (500-800ms)

After (React 18):

  • Average form submission: 2-3 renders (60% reduction)
  • Total JavaScript time: 120-200ms per form (65% improvement)
  • User complaints: 3 tickets in same period (93% reduction)
  • Mobile performance: Dramatically improved (200-300ms)

Business Metrics:

  • Form completion rate: Increased 12%
  • Time to submit: Reduced 35%
  • User satisfaction (NPS): Increased from 42 to 67
  • Support ticket volume: Reduced 87%

Key Learnings:

  1. Automatic batching is a "free" upgrade: Most apps need zero code changes
  2. Biggest impact in async code: Promises, timeouts, event listeners
  3. flushSync is rarely needed: Only for DOM measurements or third-party integrations
  4. Measure before optimizing: React Profiler revealed the problem clearly
  5. Mobile benefits most: Automatic batching reduces overhead on slower devices
  6. Combine with other React 18 features: Transitions and Suspense also improved performance

Migration Checklist:

// 1. Upgrade React and ReactDOM
npm install react@18 react-dom@18

// 2. Replace ReactDOM.render with createRoot
// Before:
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));

// After:
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(<App />);

// 3. Test thoroughly (automatic batching may expose bugs)
// - Check for race conditions
// - Verify useEffect dependencies
// - Test async flows

// 4. Remove manual batching (optional cleanup)
// Before:
unstable_batchedUpdates(() => {
setState1();
setState2();
});

// After (automatic):
setState1();
setState2(); // Batched automatically

// 5. Monitor performance with React DevTools Profiler

Edge Case Discovered:

One form had a bug exposed by automatic batching:

// Bug: Race condition hidden by multiple renders
useEffect(() => {
if (submitStatus === 'success') {
setTimeout(() => setSubmitStatus(null), 3000);
}
}, [submitStatus]);

// React 17: Multiple renders meant effect ran multiple times
// React 18: Single batched render, effect ran once (correct)
// Exposed bug: timeout wasn't cleaned up properly

// Fix: Add cleanup
useEffect(() => {
if (submitStatus === 'success') {
const timer = setTimeout(() => setSubmitStatus(null), 3000);
return () => clearTimeout(timer);
}
}, [submitStatus]);

This bug was actually fixed by React 18's batching, demonstrating that automatic batching can improve correctness, not just performance.

</details>


<details> <summary><strong>⚖️ Trade-offs: Automatic Batching Behavior</strong></summary>

Automatic Batching: Benefits vs. Considerations

Benefits of Automatic Batching:

1. Performance Improvements:

  • Fewer Renders: Reduces reconciliation overhead by 40-80% in typical applications
  • Fewer Commits: Less DOM manipulation = faster updates
  • Better Mobile Performance: Especially beneficial on lower-end devices
  • Smoother Animations: Fewer frames dropped during state updates
  • Lower Power Consumption: Fewer renders = less battery drain on mobile
// Performance comparison
// React 17: 5 renders in timeout
setTimeout(() => {
setState1(); // Render 1: 50ms
setState2(); // Render 2: 50ms
setState3(); // Render 3: 50ms
setState4(); // Render 4: 50ms
setState5(); // Render 5: 50ms
}, 100);
// Total: 250ms

// React 18: 1 batched render
setTimeout(() => {
setState1(); // Queued
setState2(); // Queued
setState3(); // Queued
setState4(); // Queued
setState5(); // Queued
}, 100);
// Total: 50ms (80% improvement)

2. Consistency:

  • Uniform Behavior: Batching works everywhere (event handlers, promises, timeouts)
  • Predictable Performance: No mental overhead tracking when batching applies
  • Simpler Mental Model: Don't need to know if you're in a "React event"

3. Code Simplicity:

  • No Manual Batching Needed: Remove unstable_batchedUpdates calls
  • Fewer Workarounds: Don't need to combine state or use reducers solely for batching
  • Cleaner Async Code: Natural async/await without batching hacks

Considerations and Potential Issues:

1. Exposed Race Conditions:

Automatic batching can expose bugs that were masked by multiple renders:

// ❌ Bug hidden by multiple renders (React 17)
function BuggyComponent() {
const [count, setCount] = useState(0);
const [doubled, setDoubled] = useState(0);

useEffect(() => {
setDoubled(count * 2); // Runs after every render
}, [count]);

const increment = () => {
setCount(c => c + 1); // Render 1 in React 17
console.log(doubled); // Logs old value, but...
// In React 17, effect runs, causing another render
// Bug is "fixed" by accident due to multiple renders
};

// React 18: Single batched render exposes the bug
// console.log reads stale value more obviously
}

// ✅ Fix: Use derived state or proper dependencies
function FixedComponent() {
const [count, setCount] = useState(0);
const doubled = count * 2; // Derived state (better)

const increment = () => {
setCount(c => c + 1);
// doubled updates in same render (no bug)
};
}

2. Changed Timing for Effects:

Effects may run at different times with batching:

// Timing difference
function TimingSensitive() {
const [a, setA] = useState(0);
const [b, setB] = useState(0);

useEffect(() => {
console.log('Effect ran', a, b);
}, [a, b]);

const update = () => {
setA(1);
setB(2);
};

// React 17: Effect runs twice (once after setA, once after setB)
// Output: "Effect ran 1 0", "Effect ran 1 2"

// React 18: Effect runs once (after batched render)
// Output: "Effect ran 1 2"
}

Impact: If effects had side effects that depended on order, behavior changes.

3. DOM Reads Between Updates:

Reading DOM between updates may return stale values:

// ❌ Stale DOM reads
function ScrollPosition() {
const [items, setItems] = useState([]);
const [scrollPos, setScrollPos] = useState(0);
const listRef = useRef();

const addItems = (newItems) => {
setItems([...items, ...newItems]);

// In React 17: DOM already updated here (separate render)
// In React 18: DOM not updated yet (batched)
const height = listRef.current.scrollHeight; // Stale in React 18!
setScrollPos(height);
};
}

// ✅ Fix: Use flushSync for immediate DOM updates
import { flushSync } from 'react-dom';

const addItems = (newItems) => {
flushSync(() => {
setItems([...items, ...newItems]);
});

// DOM is updated synchronously
const height = listRef.current.scrollHeight; // Fresh value
setScrollPos(height);
};

4. Third-Party Library Integration:

Some libraries expect immediate DOM updates:

// Library that measures DOM after state updates
class OldLibrary {
updateContent(newContent) {
// Assumes DOM is updated immediately
const height = this.element.offsetHeight;
this.adjustLayout(height);
}
}

// ❌ Broken in React 18 (batching delays DOM update)
function IntegrationComponent() {
const [content, setContent] = useState('');
const libraryRef = useRef(new OldLibrary());

const updateContent = (newContent) => {
setContent(newContent);
libraryRef.current.updateContent(newContent); // Reads stale DOM!
};
}

// ✅ Fix: Use flushSync
const updateContent = (newContent) => {
flushSync(() => {
setContent(newContent);
});
libraryRef.current.updateContent(newContent); // DOM updated
};

5. Testing Challenges:

Tests may need updates for batching behavior:

// ❌ Test that assumes immediate updates
test('updates state', () => {
const { getByText } = render(<Counter />);
const button = getByText('Increment');

fireEvent.click(button);

// React 17: Update already applied
// React 18: Update batched, not applied yet
expect(getByText('Count: 1')).toBeInTheDocument(); // May fail!
});

// ✅ Fix: Use waitFor or act
test('updates state', async () => {
const { getByText } = render(<Counter />);
const button = getByText('Increment');

fireEvent.click(button);

await waitFor(() => {
expect(getByText('Count: 1')).toBeInTheDocument();
});
});

When to Use flushSync (Opt-Out of Batching):

Use flushSync when:

  1. DOM Measurements: Need immediate access to updated DOM dimensions
  2. Third-Party Integrations: Library expects synchronous DOM updates
  3. Focus Management: Need to focus element immediately after render
  4. Scroll Position: Setting scroll position based on new content
  5. Canvas/WebGL: Rendering to canvas immediately after state change
// Example: Focus management
import { flushSync } from 'react-dom';

function Modal({ onClose }) {
const inputRef = useRef();

useEffect(() => {
flushSync(() => {
setModalOpen(true);
});

// Focus input immediately (needs updated DOM)
inputRef.current?.focus();
}, []);

return <input ref={inputRef} />;
}

⚠️ flushSync Downsides:

  • Forces synchronous render (blocks main thread)
  • Defeats batching performance benefits
  • Can cause jank if overused
  • Should be rare (<1% of updates)

Performance Trade-off Matrix:

ScenarioReact 17React 18 (Batched)React 18 + flushSync
Event handler updatesBatched (fast)Batched (fast)Synchronous (slower)
setTimeout updatesNot batched (slow)Batched (fast)Synchronous (slower)
Promise updatesNot batched (slow)Batched (fast)Synchronous (slower)
DOM measurementsN/AStale (need flushSync)Fresh (slower)
Render countHighLowMedium
Main thread blockingMediumLowHigh (if overused)

Migration Risk Assessment:

Low Risk (Easy Migration):

  • Simple CRUD apps
  • Forms without complex DOM interactions
  • Standard data fetching patterns
  • Apps already using unstable_batchedUpdates

Medium Risk (Test Thoroughly):

  • Apps with custom animations
  • Complex drag-and-drop interfaces
  • Real-time collaborative features
  • Heavy third-party library usage

High Risk (Careful Migration):

  • Apps relying on specific render timing
  • Canvas/WebGL rendering tied to state
  • Complex focus management
  • Tight integration with legacy libraries

Best Practices:

DO: ✅ Embrace automatic batching (it's usually better) ✅ Use flushSync sparingly for specific needs ✅ Test async code paths thoroughly ✅ Update tests to handle batching ✅ Profile before and after migration

DON'T: ❌ Overuse flushSync (defeats the purpose) ❌ Rely on specific render timing ❌ Use flushSync for performance (it's slower) ❌ Skip testing after upgrading ❌ Assume no bugs will be exposed

Monitoring Automatic Batching Impact:

// Add telemetry to measure batching benefits
let renderCount = 0;

function ProfiledComponent() {
useEffect(() => {
renderCount++;

// Report to analytics every 10 renders
if (renderCount % 10 === 0) {
reportMetric('component_renders', renderCount);
}
});

return (/* ... */);
}

// Compare React 17 vs React 18 metrics:
// React 17 average: 150 renders/session
// React 18 average: 65 renders/session (57% reduction)

Summary:

Automatic batching is a net positive for 95% of applications. The performance benefits far outweigh the rare edge cases. When issues arise, they usually indicate bugs that were masked by multiple renders. The key is thorough testing during migration and judicious use of flushSync for the few cases that require immediate DOM updates.

</details>


<details> <summary><strong>💬 Explain to Junior: Why Batching Makes Apps Faster</strong></summary>

The Restaurant Kitchen Analogy:

Imagine you're a chef in a restaurant kitchen. When orders come in, you have two strategies:

React 17 (Selective Batching):

  • Orders from the waitstaff (React events): You collect all their requests and cook everything together. Efficient!
  • Orders from the phone (timeouts/promises): You cook each dish immediately, one by one. Inefficient!

React 18 (Automatic Batching):

  • All orders, regardless of source: You collect them and cook everything together. Always efficient!

Simple Code Example:

// React 17 behavior
function updateProfile() {
setName('Alice'); // Like receiving 3 phone orders
setAge(25); // You cook each one separately
setEmail('a@b.com'); // Result: 3 cooking sessions
}

setTimeout(() => {
setName('Alice'); // 3 separate cooking sessions
setAge(25); // Very inefficient!
setEmail('a@b.com'); // User sees 3 UI updates flash
}, 1000);

// React 18 behavior (automatic batching)
function updateProfile() {
setName('Alice'); // Collect all 3 orders
setAge(25); // Cook together
setEmail('a@b.com'); // Result: 1 cooking session
}

setTimeout(() => {
setName('Alice'); // Still batched! (NEW in React 18)
setAge(25); // Cook together
setEmail('a@b.com'); // Result: 1 smooth UI update
}, 1000);

What This Means for You:

Before React 18, if you updated multiple states inside a setTimeout, Promise, or async function, React would re-render your component multiple times. Users might see flickering or intermediate states.

After React 18, React automatically groups these updates together, resulting in just one re-render. This makes your app faster and smoother, with zero code changes!

Simple Explanation for Interviews:

"Automatic batching in React 18 means that React now groups multiple state updates into a single re-render, regardless of where the updates happen. Before React 18, this only worked in event handlers. Now it works everywhere: timeouts, promises, async functions, and native event listeners.

The benefit is better performance with fewer re-renders, and more consistent behavior across your entire app. It's enabled automatically when you upgrade to React 18's new root API with createRoot.

If you ever need synchronous updates for specific cases like measuring the DOM, you can use flushSync from react-dom, but that's rarely needed."

Common Interview Questions:

Q: What's the difference between React 17 and React 18 batching?

A: "In React 17, batching only worked in React event handlers like onClick. If you updated state in a setTimeout or Promise, each update caused a separate render. React 18 introduced automatic batching that works everywhere, reducing renders and improving performance."

Q: How do you opt out of automatic batching?

A: "Use flushSync from react-dom. Wrap your state updates in flushSync(() => { setState(...) }) to force an immediate synchronous render. This is useful for DOM measurements or integrating with libraries that expect immediate updates, but should be used sparingly."

Q: Does automatic batching work with async/await?

A: "Yes! That's one of the biggest improvements. In React 18, state updates after an await are automatically batched together. This wasn't the case in React 17."

Visual Comparison:

// Scenario: Updating multiple states after data fetch

// React 17 (without batching)
async function loadData() {
const data = await fetchData();

setData(data); // Render 1 (user sees loading stop)
setLoading(false); // Render 2 (loading spinner disappears)
setError(null); // Render 3 (error message clears)

// User experience: Sees 3 quick flashes of UI updates
}

// React 18 (automatic batching)
async function loadData() {
const data = await fetchData();

setData(data); // Queued
setLoading(false); // Queued
setError(null); // Queued

// All updates happen together in 1 smooth render
// User experience: Single smooth transition
}

Real-World Example - Form Validation:

// Form with validation
function SignupForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState({});
const [isValid, setIsValid] = useState(false);

const validateForm = () => {
const newErrors = {};

if (!email.includes('@')) {
newErrors.email = 'Invalid email';
}

if (password.length < 8) {
newErrors.password = 'Password too short';
}

// In React 17: These could cause multiple renders in async code
// In React 18: Always batched = 1 smooth update
setErrors(newErrors);
setIsValid(Object.keys(newErrors).length === 0);
};

// Debounced validation (runs in setTimeout)
useEffect(() => {
const timer = setTimeout(validateForm, 500);
return () => clearTimeout(timer);
}, [email, password]);

return (
<form>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{errors.email && <span>{errors.email}</span>}

<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{errors.password && <span>{errors.password}</span>}

<button disabled={!isValid}>Sign Up</button>
</form>
);
}

// React 18 benefit: Validation runs in setTimeout (after debounce)
// All state updates (setErrors, setIsValid) are batched
// User sees smooth validation feedback, no flickering

When You Need to Know About flushSync:

// Rare case: Measuring DOM immediately after update
import { flushSync } from 'react-dom';

function ExpandableSection() {
const [isOpen, setIsOpen] = useState(false);
const contentRef = useRef();

const toggle = () => {
flushSync(() => {
setIsOpen(!isOpen); // Update immediately
});

// Measure updated DOM height for animation
const height = contentRef.current.offsetHeight;
animateToHeight(height);
};

return (
<div>
<button onClick={toggle}>Toggle</button>
<div ref={contentRef} style={{ display: isOpen ? 'block' : 'none' }}>
{/* content */}
</div>
</div>
);
}

// Without flushSync: contentRef.current.offsetHeight reads old height
// With flushSync: DOM updated immediately, we get correct height

Key Takeaways:

  1. Automatic batching is automatic: No code changes needed (usually)
  2. Works everywhere: Timeouts, promises, async/await, native events
  3. Better performance: Fewer renders = faster app
  4. Smoother UX: No flickering between updates
  5. flushSync is rare: Only for special cases like DOM measurements

Remember: Think of batching like collecting multiple tasks and doing them all at once, instead of doing each task immediately as it arrives. It's more efficient and the user gets a better experience!

</details>



Source: concurrent-01a-features.md