useState in Depth
Reviewed & published by Brayan K
useState stores a value that React remembers between renders, and gives you a setter to change it. In this lesson you'll go deeper: why setting state is asynchronous, why React batches multiple updates into one re-render, and why the functional updater form setX(prev => ...) is the only correct way to update state from its previous value.
Part of the free React course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Why setState does NOT change the variable immediately
- How a stale closure makes count + 1 only move once
- The functional updater form setX(prev => ...)
- What batching is and why it means one re-render
- When you MUST use the updater form vs the value form
- How React 18 batches updates in promises and timeouts too
1️⃣ State Updates Are Asynchronous
When you call setCount, React does not change the count variable on the spot. It schedules a re-render. During the current render, count is a fixed, captured value (a closure). So calling setCount(count + 1) three times reads the same old count all three times — they all compute the same next value.
function Counter() {
const [count, setCount] = useState(0);
function handleTriple() {
setCount(count + 1); // reads 0 -> 1
setCount(count + 1); // reads 0 -> 1 (count is still 0!)
setCount(count + 1); // reads 0 -> 1
} // result: 1, not 3
return <button onClick={handleTriple}>{count}</button>;
}// Simulating what React sees when you call the setter 3 times in one event.
// During THIS render, "count" is a fixed value captured by the closure.
let count = 0; // pretend this is the current render's state value
// You call setCount(count + 1) three times in a click handler:
const update1 = count + 1; // reads 0 -> 1
const update2 = count + 1; // reads 0 -> 1 (count is STILL 0 here!)
const update3 = count + 1; // reads 0 -> 1
console.log(update1, update2, update3); // 1 1 1
// React batches them: last write wins, so the screen shows 1, not 3.
console.log("Final count on screen:", update3); // 1
// Lesson: when the next value depends on the previous, don't read "count".2️⃣ The Functional Updater Form
When the next value depends on the previous one, pass a function to the setter: setCount(prev => prev + 1). React calls it with the latest pending value, so chained calls stack correctly — even under batching.
function handleTriple() {
setCount(prev => prev + 1); // 0 -> 1
setCount(prev => prev + 1); // 1 -> 2
setCount(prev => prev + 1); // 2 -> 3 ✅ now it reaches 3
}// The FUNCTIONAL UPDATER form: setCount(prev => prev + 1)
// React calls your function with the latest PENDING value each time,
// so three calls correctly stack up.
let pending = 0; // React's running pending value
const inc = prev => prev + 1; // what you pass to setCount
pending = inc(pending); // 0 -> 1
pending = inc(pending); // 1 -> 2
pending = inc(pending); // 2 -> 3
console.log("Final count on screen:", pending); // 3
// Each call builds on the previous result — correct even when batched.Now your turn — write the updater for a toggle. It should return the opposite of its argument:
// 🎯 YOUR TURN — fill in each ___ then press "Try it Yourself".
// Make a toggle that flips between true and false correctly,
// even if called twice in a row. Use the functional updater form.
let value = false;
// 1) Write an updater that returns the OPPOSITE of its argument.
const toggle = prev => ___; // 👉 e.g. !prev
value = toggle(value); // false -> true
value = toggle(value); // true -> false
value = toggle(value); // false -> true
console.log(value);
// ✅ Expected output:
// true3️⃣ Batching — One Re-render for Many Updates
For performance, React batches all the state updates that happen in a single event and re-renders once at the end. Set three pieces of state in one click handler and your component still renders only one extra time. Since React 18, this automatic batching also covers updates inside setTimeout, promises, and native handlers.
// BATCHING: multiple setState calls in one event = ONE re-render.
// Here we count how many times the component would re-render.
let renders = 0;
function render() { renders++; }
// Inside a single click handler you update three pieces of state:
function handleClick() {
// setName(...), setAge(...), setActive(...) all queued together
// React flushes them once, then renders a single time:
render(); // <- only ONE render for all three updates
}
handleClick();
console.log("Re-renders after 3 setState calls:", renders); // 1
// Without batching it would be 3. Batching keeps the UI fast.🧩 Reorder Challenge
These lines build a correct counter component, but they're scrambled. Put them in the right order:
A. return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
B. function Counter() {
C. const [count, setCount] = useState(0);
D. }Common Errors (and the fix)
- "My counter skips numbers / only moves by 1." — You used setCount(count + 1) multiple times. Switch to setCount(c => c + 1).
- Reading state right after setting it shows the old value. — Expected: state updates after the re-render. Use the value you computed, or read it in the next render / an effect.
- Mutating state directly (arr.push(x); setArr(arr)) skips the re-render. Create a new array: setArr(a => [...a, x]).
📋 Quick Reference
| Need | Form |
|---|---|
| Set to a fixed value | setX(5) |
| Next depends on previous | setX(prev => prev + 1) |
| Toggle a boolean | setOpen(o => !o) |
| Append to array | setArr(a => [...a, item]) |
| Update object field | setU(u => ({ ...u, age: 30 })) |
🧠 Quick Recall
1. count is 0. You call setCount(count + 1) twice in one handler. What shows on screen?
1. Both reads see the old 0, compute 1, and batching keeps the last write — so 1.
2. Same start, but you call setCount(c => c + 1) twice. Now what?
2. Each updater builds on the pending value: 0→1→2. The screen shows 2.
3. You set three different state variables in one click. How many extra re-renders?
One. React batches all three into a single re-render.
Mini-Challenge: A Like Counter
Simulate five "like" clicks using the functional updater form. Run it and confirm the total is 5.
// 🎯 MINI-CHALLENGE: a "like" counter that survives batching
//
// 1. Start with likes = 0.
// 2. Simulate clicking "like" 5 times using the functional updater
// form: likes = bump(likes), where bump(prev) => prev + 1.
// 3. console.log the final number of likes.
//
// ✅ Expected output:
// 5
let likes = 0;
// your code here🎉 Lesson Complete
- ✅ setState is asynchronous — it schedules a re-render, it doesn't mutate now
- ✅ A stale closure makes repeated setCount(count + 1) only move once
- ✅ Use the functional updater setX(prev => ...) when next depends on previous
- ✅ React batches updates in an event into one re-render (React 18: even in promises/timeouts)
Practice quiz
Are state updates synchronous?
- Yes, the variable changes immediately
- Only in production
- No — setState schedules a re-render; the current variable does not change immediately
- Only for objects
Answer: No — setState schedules a re-render; the current variable does not change immediately. setState schedules an update. During the current render the state variable stays the same; React re-renders with the new value afterwards.
count is 0. You call setCount(count + 1) three times in one handler. What shows on screen?
- 1
- 3
- 0
- 2
Answer: 1. Each call reads the same stale count (0) from the current render's closure, so all three compute 1. Batching keeps the last write: the screen shows 1.
How do you correctly increment based on the previous value?
- setCount(count + 1)
- count = count + 1
- setCount(count++)
- setCount(prev => prev + 1)
Answer: setCount(prev => prev + 1). Use the functional updater setCount(prev => prev + 1). React calls it with the latest pending value, so chained calls stack correctly even when batched.
Starting at 0, you call setCount(c => c + 1) twice in one handler. What shows?
- 0
- 2
- 1
- 3
Answer: 2. Each updater builds on the latest pending value: 0 -> 1 -> 2. The screen shows 2.
What is batching in React?
- Grouping multiple state updates in one event into a single re-render
- Running effects in parallel
- Caching component output
- Delaying renders by one second
Answer: Grouping multiple state updates in one event into a single re-render. React batches the state updates that happen in a single event and re-renders once at the end for performance.
You set three different state variables in one click. How many extra re-renders?
- Three
- Two
- One
- Zero
Answer: One. React batches all three updates into a single re-render, so there is just one extra render.
In React 18+, automatic batching also covers updates inside which contexts?
- Only synchronous event handlers
- setTimeout, promises, and native event handlers too
- Only class components
- Only the initial render
Answer: setTimeout, promises, and native event handlers too. Since React 18, automatic batching extends to updates inside timeouts, promises, and native handlers — not just React event handlers.
When must you use the functional updater form setX(prev => ...)?
- Never; the value form is always fine
- Only for strings
- Only on the first render
- Any time the next state depends on the previous state
Answer: Any time the next state depends on the previous state. Whenever the next state depends on the previous (counters, toggles, appending to arrays), the updater form is the only correct choice under batching/async updates.
Which correctly appends an item to an array in state without mutating?
- arr.push(x); setArr(arr)
- setArr(a => [...a, x])
- setArr(arr.push(x))
- arr[arr.length] = x; setArr(arr)
Answer: setArr(a => [...a, x]). Create a new array: setArr(a => [...a, x]). Mutating the existing array with push keeps the same reference and skips the re-render.
What is the benefit of passing a function to useState, e.g. useState(() => expensiveInit())?
- It re-runs expensiveInit on every render
- It makes the state asynchronous
- It runs expensiveInit only once, on the initial render (lazy initialization)
- It memoizes the setter
Answer: It runs expensiveInit only once, on the initial render (lazy initialization). Lazy initialization: passing a function makes React call it only on the first render to compute the initial state, avoiding expensive work on every render.
Continue this course
- Previous: Hooks (useState, useEffect)
- Next: How React Rendering Works — Render vs commit, the Virtual DOM, reconciliation, and what triggers a re-render
- Quick reference: React cheat sheet
Frequently asked questions
Why does my count only go up by 1 when I call setCount(count + 1) three times?
Each call reads the SAME stale count from this render (a closure capture). All three compute the same next value, so React batches them into one update. Use the updater form setCount(c => c + 1) so each call builds on the previous pending value.
Are state updates synchronous?
No. setState schedules an update; the variable in the current render does not change immediately. React re-renders with the new value afterwards. Reading the state variable right after calling the setter still shows the old value.
What is batching?
React groups multiple state updates that happen in the same event into a single re-render for performance. In React 18+ this also covers updates inside promises, timeouts, and native event handlers (automatic batching).
When must I use the functional updater form?
Any time the next state depends on the previous state — counters, toggles, appending to arrays — use setX(prev => ...). It is the only form that is correct when updates are batched or asynchronous.