Prevents stale closure issues, guarantees latest state
Updater with Logic
setState(prev => prev + delta)
Calculations based on current state
Safe for async updates and batching
Scenario
❌ Problematic
✅ Correct
Multiple Updates
setCount(count + 1); setCount(count + 1);
setCount(c => c + 1); setCount(c => c + 1);
Event Handler
onClick={() => setCount(count + 1)}
onClick={() => setCount(c => c + 1)}
Async Callback
setTimeout(() => setCount(count + 1), 1000)
setTimeout(() => setCount(c => c + 1), 1000)
Example: Functional updates prevent stale closures
// ❌ Problem: Both clicks only increment once totalfunction BadCounter() { const [count, setCount] = useState(0); const handleClick = () => { setCount(count + 1); // Uses stale 'count' setCount(count + 1); // Uses same stale 'count' }; return <button onClick={handleClick}>{count}</button>;}// ✅ Solution: Functional updates use latest statefunction GoodCounter() { const [count, setCount] = useState(0); const handleClick = () => { setCount(c => c + 1); // Uses latest state setCount(c => c + 1); // Uses result of previous update }; return <button onClick={handleClick}>{count}</button>; // Increments by 2}
Best Practice: Always use functional updates when the new state depends on the previous state,
especially in event handlers, async operations, and when multiple updates may be batched.
Example: Lazy initialization for expensive operations
// ❌ Bad: Reads localStorage on EVERY render (wasteful)function BadComponent() { const [data, setData] = useState( JSON.parse(localStorage.getItem('userData') || '{}') ); // This executes on every render, even though result is ignored after mount}// ✅ Good: Reads localStorage ONLY on initial mountfunction GoodComponent() { const [data, setData] = useState(() => { const saved = localStorage.getItem('userData'); return saved ? JSON.parse(saved) : {}; }); // Function executes once, subsequent renders skip it}// ✅ Good: Expensive calculation done only oncefunction DataProcessor({ rawData }) { const [processed, setProcessed] = useState(() => { console.log('Processing...'); // Logs only once return expensiveProcessing(rawData); });}
Warning: Lazy initializer function runs only once on mount. It won't re-run if props change.
For derived state from props, use useMemo or useEffect instead.
4. State Update Batching and React 18 Automatic Batching
React Version
Batching Scope
Behavior
React 17
Event handlers only
Batches updates in synthetic events, but not in setTimeout, promises,
native events
React 18+ NEW
All updates everywhere
Automatic batching in timeouts, promises, native events, and event handlers
Context
React 17 Batching
React 18 Batching
Re-renders
Event Handler
✅ Batched
✅ Batched
1 re-render for multiple setState calls
setTimeout
❌ Not batched
✅ Batched
React 18: 1 re-render; React 17: multiple
Promise.then
❌ Not batched
✅ Batched
React 18: 1 re-render; React 17: multiple
Native Event
❌ Not batched
✅ Batched
React 18: 1 re-render; React 17: multiple
API
Purpose
Usage
flushSync()
Force immediate synchronous update, opt-out of batching
flushSync(() => setState(value))
ReactDOM.unstable_batchedUpdates()
Manual batching in React 17 (deprecated in React 18)
import { useState } from 'react';import { flushSync } from 'react-dom';function Counter() { const [count, setCount] = useState(0); const [flag, setFlag] = useState(false); console.log('Render'); // Track re-renders // React 18: Both updates batched → 1 render // React 17: Both updates batched → 1 render function handleClick() { setCount(c => c + 1); setFlag(f => !f); // Single re-render after both updates } // React 18: Both updates batched → 1 render // React 17: Each update causes render → 2 renders function handleAsync() { setTimeout(() => { setCount(c => c + 1); setFlag(f => !f); // React 18: Single re-render }, 1000); } // React 18: Both updates batched → 1 render // React 17: Each update causes render → 2 renders function handlePromise() { fetch('/api').then(() => { setCount(c => c + 1); setFlag(f => !f); // React 18: Single re-render }); } // Opt-out: Force immediate synchronous updates function handleFlushSync() { flushSync(() => { setCount(c => c + 1); // Renders immediately }); flushSync(() => { setFlag(f => !f); // Renders immediately (separate) }); // Two separate re-renders }}
React 18 Benefit: Automatic batching improves performance by reducing re-renders. Multiple
state updates in any context are batched into a single re-render, leading to better performance and more
consistent behavior.
function UserForm() { const [user, setUser] = useState({ name: '', email: '', age: 0, address: { city: '', country: '' } }); // ❌ Wrong: Loses all other properties const updateNameBad = (name) => { setUser({ name }); // user now only has 'name', lost email, age, address }; // ✅ Correct: Spreads existing state, updates one field const updateName = (name) => { setUser(prev => ({ ...prev, name })); }; // ✅ Multiple fields at once const updateMultiple = () => { setUser(prev => ({ ...prev, name: 'John', age: 30 })); }; // ✅ Nested object update const updateCity = (city) => { setUser(prev => ({ ...prev, address: { ...prev.address, city } })); }; // ✅ Dynamic property name const updateField = (field, value) => { setUser(prev => ({ ...prev, [field]: value })); }; return ( <form> <input value={user.name} onChange={e => updateName(e.target.value)} /> <input value={user.email} onChange={e => updateField('email', e.target.value)} /> </form> );}
Warning:useState does NOT merge objects automatically like class component
setState. Always use the spread operator {...prev, ...changes} to preserve unmodified
properties.