A common React performance pitfall is making multiple state updates inside loops without proper batching, which can lead to:
- Unnecessary re-renders
- Performance bottlenecks
- Inconsistent UI state
- Race conditions
The Problem
// ❌ Wrong - multiple unbatched state updates in loop
function processItems(items) {
items.forEach(item => {
setItems(prev => [...prev, item]); // Triggers re-render each time
});
}
Why this is problematic:
- Each
setStatetriggers a re-render - React may process updates separately (no batching)
- Can cause layout thrashing
- Leads to poor performance with large datasets
Correct Solutions
1. Single Batch Update (Recommended)
// ✅ Correct - single update with all changes
function processItems(items) {
setItems(prev => [...prev, ...items]); // One update
}
2. UseReducer for Complex State Logic
// ✅ Better for complex operations
const [state, dispatch] = useReducer(reducer, initialState);
function processItems(items) {
dispatch({ type: 'ADD_ITEMS', payload: items });
}
// Reducer handles batch update
function reducer(state, action) {
switch (action.type) {
case 'ADD_ITEMS':
return [...state, ...action.payload];
// other cases
}
}
3. Manual Batching in Class Components
// ✅ Class component batching
processItems(items) {
this.setState(prev => ({
items: [...prev.items, ...items]
}));
}
4. Using ReactDOM.unstable_batchedUpdates (Legacy)
// ⚠️ Last resort for special cases
import { unstable_batchedUpdates } from 'react-dom';
unstable_batchedUpdates(() => {
items.forEach(item => {
setItems(prev => [...prev, item]);
});
});
When Multiple Updates Are Necessary
If you truly need sequential updates (e.g., animations):
// ✅ Using functional updates to chain state changes
function sequentialUpdates() {
setCount(prev => prev + 1);
setFlag(prev => !prev);
// React 18+ batches these automatically
}
Common Anti-Patterns to Avoid
- AJAX calls in loops:
items.forEach(async item => {
const result = await fetchItem(item.id);
setResults(prev => [...prev, result]); // ❌
});
- Timers with state updates:
for (let i = 0; i < 10; i++) {
setTimeout(() => setCount(prev => prev + 1), i * 100); // ❌
}
- Event listeners in loops:
elements.forEach(el => {
el.addEventListener('click', () => {
setClicks(prev => prev + 1); // ❌
});
});
Performance Optimization Techniques
- Debounce/Rapid Updates:
const [value, setValue] = useState(0);
const debouncedSetValue = useMemo(() => debounce(setValue, 100), []);
fastEvents.forEach(event => {
debouncedSetValue(event.data); // Only final update commits
});
- Virtualization for Large Lists:
// Use react-window or react-virtualized
<List height={600} itemCount={1000} itemSize={35}>
{Row}
</List>
- Web Workers for Heavy Processing:
const worker = new Worker('processor.js');
worker.onmessage = (e) => {
setResults(e.data); // Single update
};
worker.postMessage(largeDataset);
React 18+ Automatic Batching
React 18 improves this with automatic batching:
// All these are batched together in React 18
function handleClick() {
setName('John');
setAge(30);
setActive(true);
}
However, loops and asynchronous operations still benefit from manual batching approaches.
