
ReactJS
React 18 introduced automatic batching for state updates. Understanding how updates are grouped, when renders happen, and the timing of state changes is crucial for predictable, performant applications.
Batching groups multiple state updates into a single render. Without batching, each setState call triggers a render, causing performance issues and UI flickering.
In React 18, batching happens automatically for promises, setTimeout, and event handlers. This means multiple setters in a row update once, not separately.
import { useState } from 'react';
function BatchingDemo() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
const [renderCount, setRenderCount] = useState(0);
// ❌ WITHOUT UNDERSTANDING BATCHING
// This looks like 3 renders, but React 18 batches it to 1
const handleMultipleUpdates = () => {
setCount(count + 1);
setName('Updated');
setRenderCount(renderCount + 1);
// React batches these into ONE render cycle
};
// Multiple updates in async still batch in React 18
const handleAsyncUpdates = async () => {
const data = await fetch('/api/data').then(r => r.json());
setCount(data.count); // Batched!
setName(data.name); // Batched!
// Both updates trigger ONE render
};
// Updates in promises are batched
const handlePromiseUpdates = () => {
Promise.resolve()
.then(() => setCount(count + 1)) // Batched
.then(() => setName('Changed')); // Batched together
};
return (
<div>
<p>Count: {count}</p>
<p>Name: {name}</p>
<p>Renders: {renderCount}</p>
<button onClick={handleMultipleUpdates}>Multiple Updates</button>
<button onClick={handleAsyncUpdates}>Async Updates</button>
</div>
);
}<details>
<summary>📚 More Examples</summary>
// Example: Batching across different update sources
function AdvancedBatchingDemo() {
const [count, setCount] = useState(0);
const [status, setStatus] = useState('idle');
const [error, setError] = useState<string | null>(null);
// Event handler: ALL batched to 1 render
const handleEventBatching = () => {
setCount((c) => c + 1);
setStatus('updating');
setError(null);
};
// Promise chain: ALL batched to 1 render
const handlePromiseBatching = () => {
Promise.resolve()
.then(() => {
setCount((c) => c + 1);
setStatus('processing');
})
.then(() => setError(null));
};
// Async function: ALL batched to 1 render
const handleAsyncBatching = async () => {
setStatus('loading');
setError(null);
try {
const result = await fetch('/api/data');
const data = await result.json();
setCount(data.count);
setStatus('loaded');
} catch (err) {
setError((err as Error).message);
setStatus('error');
}
};
return (
<div>
<p>Count: {count}, Status: {status}</p>
{error && <p>Error: {error}</p>}
<button onClick={handleEventBatching}>Event Batch</button>
<button onClick={handlePromiseBatching}>Promise Batch</button>
<button onClick={handleAsyncBatching}>Async Batch</button>
</div>
);
}</details>
State updates are not immediately visible. They queue up and apply during the next render. Understanding when state changes are visible prevents bugs with stale values.
When you call a setState function, it doesn't immediately change the value. Instead, React queues the update for the next render cycle. Within that same event handler, the state value stays the same.
import { useState, useCallback } from 'react';
function TimingDemo() {
const [count, setCount] = useState(0);
// State doesn't change until next render
const handleClick = () => {
console.log('Before:', count); // 0
setCount(count + 1);
console.log('After setter:', count); // Still 0!
// Count won't be 1 until this handler finishes
// and React renders the component again
};
// To use updated state immediately, use callback
const handleClickWithCallback = () => {
const newCount = count + 1;
setCount(newCount);
// Use newCount immediately, not state
console.log('New value:', newCount);
};
// Functional updates give you previous value
const handleClickFunctional = () => {
setCount((prev) => {
const newCount = prev + 1;
console.log('In updater:', newCount);
return newCount;
});
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Click (see console)</button>
<button onClick={handleClickWithCallback}>Click with local var</button>
<button onClick={handleClickFunctional}>Click with functional</button>
</div>
);
}<details>
<summary>📚 More Examples</summary>
// Example: Complex timing with async operations
function FetchComponent() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchData = async () => {
// These updates batch together
setLoading(true);
setError(null);
try {
const response = await fetch('/api/data');
const json = await response.json();
// These updates also batch together (different batch from above)
setData(json);
setLoading(false);
} catch (err) {
// These batch together
setError((err as Error).message);
setLoading(false);
}
};
// Show which batch happened
const handleClick = () => {
setLoading(true);
setTimeout(() => {
// Different batch - setLoading(false) above was already rendered
setLoading(false);
}, 0);
};
return (
<div>
{loading && <p>Loading...</p>}
{data && <p>Data: {JSON.stringify(data)}</p>}
{error && <p>Error: {error}</p>}
<button onClick={fetchData}>Fetch</button>
<button onClick={handleClick}>Delayed Update</button>
</div>
);
}</details>
In rare cases, you need updates to render immediately without batching. React provides `flushSync` for this.
`flushSync` forces React to render synchronously, flushing all batched updates immediately. Use sparingly, only when necessary for DOM measurements or third-party library integration.
import { useState } from 'react';
import { flushSync } from 'react-dom';
function FlushSyncDemo() {
const [count, setCount] = useState(0);
const [items, setItems] = useState<string[]>([]);
// ❌ NORMAL: Updates batch, list might jump visually
const addItemBad = () => {
setCount(count + 1);
setItems([...items, `Item ${count}`]);
// Both render together
};
// ✅ GOOD: Force sequential renders for smooth UX
const addItemGood = () => {
flushSync(() => {
setCount(count + 1);
});
// Count renders now
flushSync(() => {
setItems([...items, `Item ${count + 1}`]);
});
// List renders now
};
// Real use case: DOM measurement
const handleMeasure = () => {
setCount(count + 1);
// Measure DOM after render completes
flushSync(() => {
// Force render now so DOM is updated
});
const height = document.getElementById('container')?.offsetHeight;
console.log('Height:', height);
};
return (
<div id="container">
<p>Count: {count}</p>
<ul>
{items.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
<button onClick={addItemBad}>Add Normal</button>
<button onClick={addItemGood}>Add with flushSync</button>
<button onClick={handleMeasure}>Measure DOM</button>
</div>
);
}<details>
<summary>📚 More Examples</summary>
// Example: flushSync with event handlers and integration
function ThirdPartyIntegration() {
const [value, setValue] = useState('');
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
// Update React state synchronously
flushSync(() => {
setValue(newValue);
});
// Now the DOM is updated, safe to integrate with third-party
if (window.externalLibrary) {
window.externalLibrary.update(newValue);
}
};
return (
<div>
<input value={value} onChange={handleInputChange} />
<p>Value: {value}</p>
</div>
);
}
// Example: Sequential renders for animations
function AnimatedCounter() {
const [count, setCount] = useState(0);
const [displayCount, setDisplayCount] = useState(0);
const increment = () => {
const newCount = count + 1;
// Update count first
flushSync(() => {
setCount(newCount);
});
// Animate to display it
flushSync(() => {
setDisplayCount(newCount);
});
};
return (
<div>
<p style={{ fontSize: `${displayCount * 20}px` }}>Count: {displayCount}</p>
<button onClick={increment}>Increment</button>
</div>
);
}</details>
React 18 introduced `useTransition` and `useDeferredValue` for fine-grained control over update priority.
Transitions let you mark updates as non-urgent. They can be interrupted by urgent updates (like user input), enabling responsive UIs even with heavy computations.
import { useState, useTransition } from 'react';
function TransitionDemo() {
const [searchTerm, setSearchTerm] = useState('');
const [filteredList, setFilteredList] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
// Generate large list for demo
const allItems = Array.from({ length: 10000 }, (_, i) => `Item ${i}`);
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Urgent update: input responds immediately
setSearchTerm(value);
// Non-urgent update: filter happens in background
startTransition(() => {
const filtered = allItems.filter((item) =>
item.toLowerCase().includes(value.toLowerCase())
);
setFilteredList(filtered);
});
};
return (
<div>
<input
value={searchTerm}
onChange={handleSearch}
placeholder="Search..."
/>
{isPending && <p>Filtering...</p>}
<p>Results: {filteredList.length}</p>
<ul>
{filteredList.slice(0, 10).map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}<details>
<summary>📚 More Examples</summary>
// Example: useTransition with async operations
function AsyncTransitionDemo() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
const handleSearch = async (searchQuery: string) => {
setQuery(searchQuery);
// Mark async work as non-urgent
startTransition(async () => {
const response = await fetch(`/api/search?q=${searchQuery}`);
const data = await response.json();
setResults(data);
});
};
return (
<div>
<input
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search"
/>
{isPending && <p>Loading results...</p>}
<ul>
{results.map((result) => (
<li key={result}>{result}</li>
))}
</ul>
</div>
);
}
// Example: useDeferredValue for expensive computations
import { useDeferredValue, useMemo } from 'react';
function DeferredValueDemo() {
const [input, setInput] = useState('');
const deferredInput = useDeferredValue(input);
// This expensive computation uses deferred value
const results = useMemo(() => {
let count = 0;
for (let i = 0; i < 100000; i++) {
if (String(i).includes(deferredInput)) count++;
}
return count;
}, [deferredInput]);
return (
<div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type..."
/>
<p>Input: {input}</p>
<p>Matches: {results}</p>
</div>
);
}</details>
┌─────────────────────┬──────────────────┬─────────────────┐
│ Context │ Batching Status │ Behavior │
├─────────────────────┼──────────────────┼─────────────────┤
│ Event handlers │ Auto batched │ Single render │
│ Promises/async │ Auto batched │ Single render │
│ setTimeout │ Auto batched │ Single render │
│ flushSync │ Not batched │ Immediate │
│ Transitions │ Low priority │ Can be deferred │
│ useDeferredValue │ Deferred │ Delayed render │
└─────────────────────┴──────────────────┴─────────────────┘Ready to practice? Challenges | Next: Complex State Structures
Resources
Ojasa Mirai
Master AI-powered development skills through structured learning, real projects, and verified credentials. Whether you're upskilling your team or launching your career, we deliver the skills companies actually need.
Learn Deep • Build Real • Verify Skills • Launch Forward