React Automatic Batching: How Multiple State Updates Work Behind the Scenes - ajayupreti/Tech.Blogs GitHub Wiki
React's automatic batching is one of the most important performance optimizations that happens transparently in your applications. Understanding how it works under the hood can help you write more efficient React code and debug performance issues effectively.
Automatic batching is React's mechanism of grouping multiple state updates into a single re-render for better performance. Instead of re-rendering the component after each individual state update, React intelligently batches these updates together and performs only one re-render at the end.
function MyComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
const handleClick = () => {
setCount(count + 1); // Doesn't re-render yet
setName('John'); // Doesn't re-render yet
// Component re-renders only once with both updates
};
return (
<div>
<p>Count: {count}</p>
<p>Name: {name}</p>
<button onClick={handleClick}>Update Both</button>
</div>
);
}
In React 17 and earlier versions, batching only occurred within React event handlers (like onClick
, onChange
, etc.). Updates in promises, timeouts, or native event handlers were not batched:
// React 17 - These would cause separate re-renders
setTimeout(() => {
setCount(count + 1); // Re-render 1
setName('John'); // Re-render 2
}, 1000);
fetch('/api/data').then(() => {
setCount(count + 1); // Re-render 1
setName('John'); // Re-render 2
});
React 18 introduced automatic batching for all updates, regardless of where they originate:
// React 18 - These are now batched into a single re-render
setTimeout(() => {
setCount(count + 1); // Batched
setName('John'); // Batched - only one re-render
}, 1000);
fetch('/api/data').then(() => {
setCount(count + 1); // Batched
setName('John'); // Batched - only one re-render
});
When you call a state setter function, React doesn't immediately update the state. Instead, it adds the update to an internal queue:
// Simplified internal structure
const updateQueue = {
updates: [],
hasScheduledWork: false
};
function scheduleUpdate(update) {
updateQueue.updates.push(update);
if (!updateQueue.hasScheduledWork) {
updateQueue.hasScheduledWork = true;
scheduleWork(); // Schedule the batched update
}
}
React uses a sophisticated scheduler that works with different priority levels:
- Synchronous Priority: Immediate updates (user interactions)
- Normal Priority: Regular state updates
- Low Priority: Background updates
// Conceptual flow
function processUpdates() {
const batchedUpdates = [];
// Collect all updates of the same priority
while (updateQueue.updates.length > 0) {
const update = updateQueue.updates.shift();
if (update.priority === currentPriority) {
batchedUpdates.push(update);
}
}
// Apply all batched updates at once
applyBatchedUpdates(batchedUpdates);
}
Once updates are batched, React goes through the reconciliation process:
- State Calculation: Compute new state from all batched updates
- Virtual DOM Diff: Compare previous and new virtual DOM trees
- Commit Phase: Apply changes to the actual DOM
// Simplified reconciliation flow
function reconcile(component, batchedUpdates) {
// Calculate new state from all updates
const newState = batchedUpdates.reduce((state, update) => {
return update.reducer(state, update.action);
}, component.state);
// Create new virtual DOM with updated state
const newVDOM = component.render(newState);
// Diff and commit changes
const changes = diff(component.vdom, newVDOM);
commit(changes);
}
function Counter() {
const [count, setCount] = useState(0);
const [doubled, setDoubled] = useState(0);
console.log('Component rendered'); // This logs only once per batch
const handleIncrement = () => {
console.log('Before updates:', count, doubled);
setCount(count + 1); // Queued
setDoubled((count + 1) * 2); // Queued
console.log('After updates:', count, doubled); // Still old values
// Component re-renders once with both updates
};
return (
<div>
<p>Count: {count}</p>
<p>Doubled: {doubled}</p>
<button onClick={handleIncrement}>Increment</button>
</div>
);
}
function AsyncUpdates() {
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const fetchData = async () => {
setLoading(true);
setError(null);
// These two updates are batched in React 18+
try {
const response = await fetch('/api/data');
const result = await response.json();
setData(result);
setLoading(false);
// These updates are also batched together
} catch (err) {
setError(err.message);
setLoading(false);
// Error and loading updates are batched
}
};
return (
<div>
{loading && <p>Loading...</p>}
{error && <p>Error: {error}</p>}
{data && <p>Data: {JSON.stringify(data)}</p>}
<button onClick={fetchData}>Fetch Data</button>
</div>
);
}
function ComplexBatching() {
const [items, setItems] = useState([]);
const [selectedId, setSelectedId] = useState(null);
const [filter, setFilter] = useState('all');
const addItem = (item) => {
setItems(prev => [...prev, item]);
setSelectedId(item.id);
setFilter('all');
// All three updates are batched together
};
// Derived state is calculated once after batching
const filteredItems = useMemo(() => {
return items.filter(item =>
filter === 'all' || item.category === filter
);
}, [items, filter]);
const selectedItem = useMemo(() => {
return items.find(item => item.id === selectedId);
}, [items, selectedId]);
return (
<div>
<button onClick={() => addItem({
id: Date.now(),
name: 'New Item',
category: 'default'
})}>
Add Item
</button>
<p>Total items: {items.length}</p>
<p>Filtered items: {filteredItems.length}</p>
<p>Selected: {selectedItem?.name || 'None'}</p>
</div>
);
}
// Without batching - causes 3 re-renders
function inefficientUpdate() {
setCount(1); // Re-render 1
setName('John'); // Re-render 2
setAge(25); // Re-render 3
}
Performance cost:
- 3 virtual DOM reconciliations
- 3 DOM updates
- 3 effect cleanups and executions
- 3 child component re-renders
// With batching - causes 1 re-render
function efficientUpdate() {
setCount(1); // Queued
setName('John'); // Queued
setAge(25); // Queued
// Single re-render with all updates
}
Performance improvement:
- 1 virtual DOM reconciliation
- 1 DOM update
- 1 effect cleanup and execution
- 1 child component re-render
Sometimes you need to force synchronous updates:
import { flushSync } from 'react-dom';
function ForceSync() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
const handleClick = () => {
flushSync(() => {
setCount(count + 1); // Forces immediate re-render
});
setName('John'); // This will cause another re-render
};
return (
<div>
<p>Count: {count}</p>
<p>Name: {name}</p>
<button onClick={handleClick}>Update</button>
</div>
);
}
React may not batch updates with different priorities:
function PriorityExample() {
const [urgentState, setUrgentState] = useState(0);
const [normalState, setNormalState] = useState(0);
const handleClick = () => {
// High priority update (user interaction)
startTransition(() => {
setUrgentState(prev => prev + 1);
});
// Lower priority update
setNormalState(prev => prev + 1);
// These might not be batched together due to different priorities
};
}
// Good - uses functional updates
const handleMultipleUpdates = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
// All updates are properly batched and applied
};
// Avoid - might not work as expected
const handleMultipleUpdates = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// All updates use the same stale value
};
// Inefficient - multiple batched updates
const processItems = (items) => {
items.forEach(item => {
setResults(prev => [...prev, processItem(item)]);
});
};
// Better - single update
const processItems = (items) => {
const processedItems = items.map(processItem);
setResults(prev => [...prev, ...processedItems]);
};
function useComplexState() {
const [state, dispatch] = useReducer(reducer, initialState);
const updateMultipleValues = () => {
dispatch({ type: 'UPDATE_MULTIPLE', payload: { /* data */ } });
// Single dispatch instead of multiple state setters
};
return [state, updateMultipleValues];
}
The React DevTools Profiler can help you visualize batching:
- Enable "Record why each component rendered"
- Look for grouped updates in the flame graph
- Check for unexpected re-renders
function useRenderCount() {
const renderCount = useRef(0);
useEffect(() => {
renderCount.current += 1;
console.log(`Component rendered ${renderCount.current} times`);
});
return renderCount.current;
}
function MyComponent() {
const renderCount = useRenderCount();
// Use this to verify batching is working
}
// React 17 - Multiple re-renders
setTimeout(() => {
setCount(count + 1); // Re-render 1
if (count === 5) {
setMessage('Done!'); // Re-render 2
}
}, 100);
// React 18 - Single re-render
setTimeout(() => {
setCount(count + 1); // Batched
if (count === 5) { // Still uses old count value!
setMessage('Done!'); // Batched
}
}, 100);
// Safe migration pattern
setTimeout(() => {
setCount(prevCount => {
const newCount = prevCount + 1;
if (newCount === 5) {
setMessage('Done!');
}
return newCount;
});
}, 100);
React's automatic batching is a powerful optimization that significantly improves application performance by reducing unnecessary re-renders. Understanding how it works behind the scenes helps you:
- Write more efficient React code
- Debug performance issues effectively
- Make informed decisions about when to opt out of batching
- Migrate smoothly between React versions
The key takeaways are:
- React 18 batches all updates automatically, regardless of context
- Batching reduces re-renders and improves performance
- Use functional updates for reliable state transitions
- Use
flushSync
when you need to opt out of batching - Consider using reducers for complex state logic
By leveraging automatic batching effectively, you can build faster, more responsive React applications that provide better user experiences.