React 17 ⚛️ Updating State Based on Previous State
When a state update depends on the previous value, React provides a functional form of the setter that receives the current state as its argument. This is not a stylistic preference; it is the correct way to express the update when the new value is derived from the old one. The direct form — reading the state variable and passing the computed value to the setter — captures the value from the render in which the handler was created, which may be stale by the time the update runs. The functional form receives the latest value from React, and it is correct in every situation.
The problem the functional form solves is subtle, which is why it is the source of so many bugs. In a simple counter that increments once per click, the direct form works. The handler reads count, adds one, and passes the result. The next render has the new value. But when the handler calls the setter twice, or when the setter is called from a timeout, a promise, or a native event listener, the direct form uses the value from the render that created the closure, not the value at the moment the update runs. The functional form eliminates the discrepancy by letting React supply the current value at the time of the update.
This chapter covers why the previous state matters, the functional form of the setter, the stale closure problem, the multiple-update problem, batching and its interaction with functional updates, and the patterns that use the functional form for numbers, strings, booleans, arrays, and objects.
Key point: Use the functional form setState(prev => newValue) whenever the new value depends on the previous value. The functional form receives the latest state from React and is correct when the setter is called multiple times, from asynchronous callbacks, or after other updates. The direct form captures the state from the render in which the handler was created, which may be stale.
Why updating based on previous state matters
The stale closure problem. A function defined inside a component captures the variables from the render in which it was created. If the function is called later — after the state has changed and the component has re-rendered — the captured variables hold the values from the earlier render. The direct form of the setter uses the captured value, which may be stale. The functional form avoids the capture by receiving the value at the time of the update.
The multiple-update problem. When the setter is called twice in the same handler with the direct form, both calls use the same value, because the state variable has not changed within the render. The result is one increment instead of two. The functional form receives the result of the first update when the second is processed, so both updates apply.
The batching problem. React batches multiple state updates into a single re-render. The batching is what makes the multiple-update problem possible: the state variable does not change between the two setter calls, because the re-render happens after both calls. The functional form is designed for this scenario.
The asynchronous problem. A setter called from a timeout, a promise, or an event listener runs after the render that created the callback. The direct form uses the state from that render, which may be several updates old. The functional form uses the current value.
The correctness problem. The direct form is correct when the new value does not depend on the previous value. It is incorrect when the new value does depend on the previous value and the update may run in a context where the state has changed. The functional form is always correct, so the rule is simple: use it whenever the new value is derived from the old one.
a. The functional form of the setter
The setter returned by useState accepts either a new value or a function. The function receives the previous state and returns the new state.
const [count, setCount] = useState(0);
// Direct form
setCount(count + 1);
// Functional form
setCount((prev) => prev + 1);
Both forms work in the simple case. The functional form is the one that remains correct when the update is deferred or repeated.
The function can be inline or named:
setCount((prev) => prev + 1);
setCount(increment);
function increment(prev) {
return prev + 1;
}
The function must return the new state. It must not mutate the previous state, because the previous state is the value React holds and may reuse.
b. The stale closure problem
A closure captures the variables from the scope in which it was created. In a component, the handler is created during the render, and it captures the state variable from that render.
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count); // the value from this render
};
return <button onClick={handleClick}>{count}</button>;
}
The handleClick closure captures count from the render in which it was created. When the button is clicked, the handler uses that captured value. If the state has changed since the render, the captured value is stale.
In the simple counter, the handler runs once and the component re-renders. The next click uses the new handler from the new render, which captures the new value. The direct form works because the handler is recreated on every render.
The problem appears when the handler is not recreated, or when the update is deferred.
c. Multiple updates in one handler
When the setter is called twice with the direct form, both calls use the same value.
const handleClick = () => {
setCount(count + 1);
setCount(count + 1);
// count increases by 1, not 2
};
The count variable is the same for both calls, because it is the value from the current render. The two setters are batched into one re-render, and the second overwrites the first with the same value.
The functional form fixes this:
const handleClick = () => {
setCount((c) => c + 1);
setCount((c) => c + 1);
// count increases by 2
};
React processes the two functional updates in sequence. The first receives the current value and returns the incremented value. The second receives the result of the first and returns the incremented value again. The final state reflects both updates.
This is the canonical example of why the functional form exists. It is not about style; it is about correctness when updates are batched and repeated.
d. Batching and functional updates
React batches state updates that occur in the same event handler, and in React 18 and later, in promises, timeouts, and native event handlers. The batching means that the component re-renders once with the final state, and the intermediate values are not rendered.
The batching is what makes the multiple-update problem possible. Without batching, each setter would trigger a re-render, and the state variable would be updated between the calls. With batching, the state variable is the same for all the calls in the batch.
The functional form is the mechanism that lets each update in a batch see the result of the previous one. The direct form sees the state from the render that created the handler, which is the state before the batch.
// Direct: all calls see the pre-batch value
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// Result: count increases by 1
// Functional: each call sees the result of the previous
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);
// Result: count increases by 3
The functional form is the correct choice when the new value depends on the old one, regardless of whether the handler calls the setter once or multiple times.
e. Functional updates in asynchronous callbacks
A setter called from a timeout, a promise, or a native event listener runs after the render that created the callback. The direct form uses the state from that render, which may be stale.
const handleClick = () => {
setTimeout(() => {
setCount(count + 1); // uses count from the render
}, 1000);
};
If the state has changed in the second between the click and the timeout, the update uses the old value. The functional form avoids this:
const handleClick = () => {
setTimeout(() => {
setCount((c) => c + 1); // uses the latest value
}, 1000);
};
The functional form receives the current state at the time the timeout runs, not the state at the time the timeout was created. This is the correct behavior.
The same applies to promises, intervals, and event listeners that are registered once and called many times.
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1); // correct
}, 1000);
return () => clearInterval(id);
}, []);
The interval callback is created once, when the effect runs. The direct form would use the state from the render in which the effect was created, which is 0. The functional form uses the current value on every tick.
f. Functional updates for different types
The functional form works for any type. The function receives the previous value and returns the new value.
Number.
setCount((c) => c + 1);
setCount((c) => c * 2);
String.
setText((t) => t + '!');
setText((t) => t.toUpperCase());
Boolean.
setIsOpen((open) => !open);
Array.
setItems((items) => [...items, newItem]);
setItems((items) => items.filter((i) => i.id !== id));
setItems((items) => items.map((i) => i.id === id ? updated : i));
Object.
setUser((user) => ({ ...user, name: 'Alice' }));
setForm((form) => ({ ...form, [field]: value }));
The array and object cases are where the functional form is most valuable, because the new value is derived from the old one by adding, removing, or updating an element. The functional form ensures that the update uses the current collection, not a stale one.
Complete Example Session
// ============================================
// PART 1: DIRECT UPDATE — WORKS ONCE
// ============================================
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>{count}</button>
);
}
// ============================================
// PART 2: DIRECT UPDATE — STALE ON MULTIPLE CALLS
// ============================================
function Counter() {
const [count, setCount] = useState(0);
const incrementTwice = () => {
setCount(count + 1);
setCount(count + 1);
// Result: count increases by 1
};
return <button onClick={incrementTwice}>{count}</button>;
}
// ============================================
// PART 3: FUNCTIONAL UPDATE — CORRECT
// ============================================
function Counter() {
const [count, setCount] = useState(0);
const incrementTwice = () => {
setCount((c) => c + 1);
setCount((c) => c + 1);
// Result: count increases by 2
};
return <button onClick={incrementTwice}>{count}</button>;
}
// ============================================
// PART 4: TOGGLE BOOLEAN
// ============================================
function Toggle() {
const [isOpen, setIsOpen] = useState(false);
return (
<button onClick={() => setIsOpen((open) => !open)}>
{isOpen ? 'Close' : 'Open'}
</button>
);
}
// ============================================
// PART 5: STRING CONCATENATION
// ============================================
function Chat() {
const [text, setText] = useState('');
const addExclamation = () => {
setText((t) => t + '!');
};
return <button onClick={addExclamation}>{text}</button>;
}
// ============================================
// PART 6: ARRAY — APPEND
// ============================================
function TodoList() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
const addTodo = () => {
setTodos((prev) => [...prev, { id: Date.now(), text: input }]);
setInput('');
};
return (
<div>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={addTodo}>Add</button>
</div>
);
}
// ============================================
// PART 7: ARRAY — REMOVE
// ============================================
const removeTodo = (id) => {
setTodos((prev) => prev.filter((t) => t.id !== id));
};
// ============================================
// PART 8: ARRAY — UPDATE
// ============================================
const toggleTodo = (id) => {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
};
// ============================================
// PART 9: OBJECT — SPREAD
// ============================================
function Profile() {
const [user, setUser] = useState({ name: 'Alice', age: 30 });
const updateName = (newName) => {
setUser((prev) => ({ ...prev, name: newName }));
};
return <button onClick={() => updateName('Bob')}>{user.name}</button>;
}
// ============================================
// PART 10: ASYNC CALLBACK WITH FUNCTIONAL UPDATE
// ============================================
function DelayedCounter() {
const [count, setCount] = useState(0);
const incrementLater = () => {
setTimeout(() => {
setCount((c) => c + 1);
}, 1000);
};
return <button onClick={incrementLater}>{count}</button>;
}
These ten parts cover the direct update that works once, the direct update that is stale on multiple calls, the functional update that is correct, toggling a boolean, string concatenation, array append, array remove, array update, object spread, and an async callback with a functional update.
Quick Reference
Setter Forms
| Form | Syntax | When to use |
|---|---|---|
| Direct | setCount(5) | New value is constant |
| Functional | setCount((c) => c + 1) | New value depends on old |
When to Use the Functional Form
| Situation | Recommendation |
|---|---|
| New value depends on old | Functional |
| Multiple updates in one handler | Functional |
| Update in async callback | Functional |
| Update in interval | Functional |
| New value is constant | Direct |
Functional Updates by Type
| Type | Example |
|---|---|
| Number | setCount((c) => c + 1) |
| String | setText((t) => t + '!') |
| Boolean | setIsOpen((o) => !o) |
| Array append | setItems((i) => [...i, item]) |
| Array remove | setItems((i) => i.filter(...)) |
| Array update | setItems((i) => i.map(...)) |
| Object | setUser((u) => ({ ...u, name: 'Alice' })) |
Direct vs Functional
| Scenario | Direct | Functional |
|---|---|---|
| One update, constant value | Correct | Correct |
| One update, derived value | May be stale | Correct |
| Multiple updates in one handler | Stale | Correct |
| Update in async callback | May be stale | Correct |
Best Practices
✅ Do This:
// Use the functional form when depending on the previous value
setCount((c) => c + 1);
// Use the functional form for multiple updates
setCount((c) => c + 1);
setCount((c) => c + 1);
// Use the functional form in async callbacks
setTimeout(() => setCount((c) => c + 1), 1000);
// Use the functional form for arrays
setItems((items) => [...items, newItem]);
// Use the functional form for objects
setUser((user) => ({ ...user, name: 'Alice' }));
❌ Don’t Do This:
// Use the direct form multiple times
setCount(count + 1);
setCount(count + 1); // ❌ increments by 1
// Use the direct form in an interval
setInterval(() => setCount(count + 1), 1000); // ❌ stale
// Rely on the state value immediately after setting
setCount(count + 1);
console.log(count); // ❌ old value
// Mutate the previous state inside the function
setItems((items) => { items.push(x); return items; }); // ❌ mutation
// Forget to return the new value
setItems((items) => { [...items, x]; }); // ❌ returns undefined
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Multiple updates collapse | Used the direct form | Use the functional form |
| Stale value in interval | Closure captured old value | Use the functional form |
| Stale value in timeout | Same | Use the functional form |
| State resets to initial | Mutation broke the reference | Create a new array or object |
| Function returns undefined | Missing return | Return the new value |
| State not updating | Mutated the previous state | Copy and modify |
Real-World Examples
1. Increment
setCount((c) => c + 1);
2. Increment Twice
setCount((c) => c + 1);
setCount((c) => c + 1);
3. Toggle
setIsOpen((o) => !o);
4. Append to Array
setItems((items) => [...items, newItem]);
5. Remove from Array
setItems((items) => items.filter((i) => i.id !== id));
6. Update in Array
setItems((items) => items.map((i) => (i.id === id ? updated : i)));
7. Update Object
setUser((user) => ({ ...user, name: 'Alice' }));
8. Update Form Field
setForm((form) => ({ ...form, [field]: value }));
9. Interval Increment
setInterval(() => setCount((c) => c + 1), 1000);
10. Timeout Increment
setTimeout(() => setCount((c) => c + 1), 1000);
Visual
Direct vs Functional
┌──────────────────────────────────────────────────────────────┐
│ DIRECT: │
│ setCount(count + 1); │
│ setCount(count + 1); │
│ └── Both use count from this render │
│ └── Result: +1 │
│ │
│ FUNCTIONAL: │
│ setCount((c) => c + 1); │
│ setCount((c) => c + 1); │
│ └── First receives 0, returns 1 │
│ └── Second receives 1, returns 2 │
│ └── Result: +2 │
└──────────────────────────────────────────────────────────────┘
Stale Closure
┌──────────────────────────────────────────────────────────────┐
│ Render 1: count = 0 │
│ └── Handler captures count = 0 │
│ │
│ User clicks: setCount(count + 1) → 1 │
│ │
│ Render 2: count = 1 │
│ └── New handler captures count = 1 │
│ │
│ In a simple counter, the handler is recreated each render, │
│ so the direct form works. │
│ │
│ But in a setTimeout created in render 1: │
│ └── The closure still captures count = 0 │
│ └── The direct form uses the stale value │
└──────────────────────────────────────────────────────────────┘
Batching
┌──────────────────────────────────────────────────────────────┐
│ const handleClick = () => { │
│ setCount((c) => c + 1); ← queued │
│ setCount((c) => c + 1); ← queued │
│ }; │
│ │
│ React processes the queue: │
│ 1. First update: receives 0, returns 1 │
│ 2. Second update: receives 1, returns 2 │
│ 3. Re-render with count = 2 │
│ │
│ The functional form lets each update see the previous one. │
└──────────────────────────────────────────────────────────────┘
Async Callback
┌──────────────────────────────────────────────────────────────┐
│ Render 1: count = 0 │
│ └── setTimeout created, captures count = 0 │
│ │
│ User clicks again: count = 1 │
│ │
│ Timeout fires: │
│ └── Direct form: setCount(0 + 1) = 1 (stale) │
│ └── Functional: setCount(c => c + 1) = 2 (correct) │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Functional form | setState((prev) => newValue) |
| Direct form | setState(newValue) |
| Use functional when | New value depends on old |
| Use functional when | Multiple updates in one handler |
| Use functional when | Update in async callback |
| Use functional when | Update in interval or timeout |
| Batching | Multiple setters, one re-render |
| Stale closure | Captured value from the render |
| Return required | The function must return the new value |
| Mutation | Never mutate the previous state |
Key takeaways:
- Use the functional form when the new value depends on the previous value. The function receives the latest state from React, so it is correct even when the update is deferred or repeated.
- The direct form captures the state from the render. If the handler was created in a render with an older state, the direct form uses that older value. The functional form avoids the capture.
- Multiple updates in one handler require the functional form. With the direct form, both calls use the same value, and the result is one update instead of two. With the functional form, each update receives the result of the previous one.
- Batching is what makes the multiple-update problem possible. React batches the updates into one re-render, so the state variable does not change between the calls. The functional form is designed for this scenario.
- Async callbacks require the functional form. A timeout, promise, or interval callback captures the state from the render in which it was created. The functional form uses the current value at the time the callback runs.
- The functional form works for every type. Numbers, strings, booleans, arrays, and objects all use the same pattern. For arrays and objects, the function returns a new value derived from the previous one.
- Never mutate the previous state. The function must return a new value. Mutating the previous state and returning it breaks React’s reference comparison and may cause the update to be skipped.
Remember: The functional form of the setter is not a stylistic preference; it is the correct way to express an update that depends on the previous state. The direct form works in the simple case, but it fails when the update is deferred, repeated, or batched. The functional form receives the latest value from React and is correct in every situation. The rule is simple: if the new value depends on the old one, use the functional form. This applies to numbers, strings, booleans, arrays, and objects, and it applies to every context — event handlers, timeouts, promises, and intervals. Understanding the difference between the two forms is understanding how React state updates actually work.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!