Updating State Correctly
Once you understand useState, the next step is learning how to update state safely. React has specific rules around state updates, and breaking them leads to subtle bugs — most commonly the "stale closure" problem where your handler reads an outdated value.
Always Use the Setter Function
Never mutate the state variable directly. Always call the setter function returned by useState:
const [count, setCount] = useState(0) // ✗ WRONG — direct mutation; React will not re-render count = count + 1 // ✓ CORRECT — setter tells React to update and re-render setCount(count + 1)
Functional Updates: prev => newValue
The setter function accepts either a new value or an updater function that receives the previous state and returns the next state:
// Direct value — fine when you don't depend on the current state: setCount(0) setCount(42) // Updater function — always safe; receives the guaranteed latest value: setCount(prev => prev + 1) setCount(prev => prev * 2) setIsOpen(prev => !prev)
The two forms look equivalent for a simple counter, but they behave differently when multiple state updates happen inside the same event handler.
The Stale Closure Problem
Because state values are snapshots captured at render time, reading count inside an event handler gives you the value from that render — not the most recent value after other updates have queued:
function Counter() {
const [count, setCount] = useState(0)
// ✗ BUG — calling this three times in a row only adds 1, not 3
function incrementThrice() {
setCount(count + 1) // count is 0, queues: set to 1
setCount(count + 1) // count is STILL 0, queues: set to 1
setCount(count + 1) // count is STILL 0, queues: set to 1
// Result: count becomes 1, not 3
}
// ✓ FIX — functional update always builds on the truly latest value
function incrementThriceCorrectly() {
setCount(prev => prev + 1) // prev=0, queues: set to 1
setCount(prev => prev + 1) // prev=1, queues: set to 2
setCount(prev => prev + 1) // prev=2, queues: set to 3
// Result: count becomes 3 ✓
}
return (
<div>
<p>{count}</p>
<button onClick={incrementThriceCorrectly}>+3</button>
</div>
)
}When Must You Use Functional Updates?
Multiple updates in one handler — when you call the setter more than once and each update depends on the last
setTimeout / setInterval callbacks — the callback closes over the state value at the time it was created; that value may be stale by the time the callback fires
Async functions — state read after an
awaitmay be staleCustom hooks — hooks that expose a setter without direct access to the latest state should always use functional updates internally
Stale State in Async Callbacks
function AutoIncrement() {
const [count, setCount] = useState(0)
function startTimer() {
// ✗ BUG — 'count' is captured at the time startTimer runs
// Every tick will set count to 1 (0 + 1), not increment continuously
setInterval(() => {
setCount(count + 1)
}, 1000)
// ✓ FIX — functional update always has the latest value
setInterval(() => {
setCount(prev => prev + 1)
}, 1000)
}
return <button onClick={startTimer}>{count}</button>
}React Batches State Updates
React does not re-render after every single setState call. It batches multiple updates that happen in the same synchronous event handler and applies them together before a single re-render. This is an important performance optimization:
function Form() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [age, setAge] = useState(0)
function resetAll() {
setName('') // \
setEmail('') // } All three are batched into ONE re-render
setAge(0) // /
}
return (/* ... */)
}Replacing vs Merging State
Unlike this.setState in class components, the useState setter replaces the state entirely — it does not merge. When you have an object in state, you must explicitly spread the previous fields:
const [user, setUser] = useState({ name: 'Alice', age: 28, role: 'dev' })
// ✗ WRONG — this replaces the whole object, losing 'age' and 'role'
setUser({ name: 'Bob' })
// user is now: { name: 'Bob' } — age and role are gone!
// ✓ CORRECT — spread the previous state, then override the changed field
setUser(prev => ({ ...prev, name: 'Bob' }))
// user is now: { name: 'Bob', age: 28, role: 'dev' } ✓Practical Pattern: Reset to Initial State
const initialForm = { name: '', email: '', message: '' }
function ContactForm() {
const [form, setForm] = useState(initialForm)
function handleChange(field, value) {
setForm(prev => ({ ...prev, [field]: value }))
}
function handleReset() {
setForm(initialForm) // reset to the original object
}
function handleSubmit(e) {
e.preventDefault()
submitToServer(form)
handleReset()
}
return (
<form onSubmit={handleSubmit}>
<input
value={form.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Name"
/>
<input
value={form.email}
onChange={(e) => handleChange('email', e.target.value)}
placeholder="Email"
/>
<textarea
value={form.message}
onChange={(e) => handleChange('message', e.target.value)}
placeholder="Message"
/>
<button type="submit">Send</button>
<button type="button" onClick={handleReset}>Reset</button>
</form>
)
}Always call the setter — never mutate state directly
Use functional updates (
prev => newValue) when the new value depends on the previous valueFunctional updates are required when making multiple updates in one handler, inside timers, or after async
awaitState updates are batched — React applies them all at once before re-rendering
The setter replaces state — spread the previous value when updating a field in an object