State & useState
useState gives per-render snapshots; use functional updates, lazy init, batching, and immutable updates to avoid stale-state bugs.
🧑🏫 Sabse pehle — simple mein samjho#
State component ki apni memory hai jo waqt ke saath badal sakti hai — jaise cricket ka live score jo har ball par update hota rehta hai. Props to parent se aate hain aur read-only hote hain, par state component khud sambhalta hai. useState se state banao; jab value badalni ho to setter (jaise setScore) call karo, aur React screen dobara bana deta hai. Bahut zaroori rule: state ko seedha mat badlo (score = 5 galat), hamesha setter use karo warna React ko pata hi nahi chalega ki update karna hai.
function LiveScore() {
const [score, setScore] = useState(0); // shuruaati score 0
// Seedha score++ mat karo — hamesha setter se badlo
return <button onClick={() => setScore(score + 6)}>Score: {score} 🏏</button>;
}
Yaad rakho: State = component ki badalne wali memory; hamesha setter se update karo, seedha mat chhedo — tabhi screen refresh hoti.
useState basics 🎯#
useState returns a [value, setter] pair. Calling the setter schedules a re-render; it does not mutate the variable in place.
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
State is a snapshot per render 🎯#
Each render captures its own count value — a constant for that render. The value doesn't change mid-render; a new render with a new value is what updates the screen. This explains the classic "off by one" bug:
function handleClick() {
setCount(count + 1); // count is 0 this render
setCount(count + 1); // still 0 → both set to 1, NOT 2
console.log(count); // logs 0 — still the snapshot value
}
Functional updates 🟢#
When the next state depends on the previous, pass an updater function. React applies them in sequence against the latest queued value.
setCount(c => c + 1);
setCount(c => c + 1); // now correctly results in +2
Rule of thumb: if new state derives from old state, use the functional form. Avoids stale-closure bugs entirely.
Real-world example: Swiggy/Zomato cart quantity stepper ⚠️🟢#
A cart row with +/− buttons is the textbook place this bug shows up. If a "bulk add" or double-tap handler calls the setter twice using the plain count + 1 form, both calls read the same snapshot from this render — so two calls only add one, not two.
function CartItemRow({ item }) {
const [qty, setQty] = useState(1);
// ⚠️ Calling the setter twice with the snapshot value only nets +1,
// because both calls close over the same `qty` from this render.
function addTwoBroken() {
setQty(qty + 1);
setQty(qty + 1); // still reads the same qty as above
}
// 🟢 Functional updates each apply against the latest queued value
function addTwoFixed() {
setQty(q => q + 1);
setQty(q => q + 1); // correctly ends up +2
}
return (
<div className="cart-row">
<span>{item.name}</span>
<button onClick={() => setQty(q => Math.max(1, q - 1))}>−</button>
<span>{qty}</span>
<button onClick={() => setQty(q => q + 1)}>+</button>
</div>
);
}
The −/+ buttons themselves are already written with the functional form (q => q + 1), which is why a fast double-tap on + correctly increments by 2 — but addTwoBroken (e.g. a "add 2 more" shortcut) would silently under-count if it used qty + 1 twice.
flowchart TD
R0["Render N: qty = 1"] --> H["Handler runs (one click/tap)"]
H --> S1["setQty(qty + 1) → setQty(2)"]
H --> S2["setQty(qty + 1) → setQty(2), qty is still 1 here"]
S1 --> Q["Queued updates: [2, 2]"]
S2 --> Q
Q --> RN["Re-render once: qty = 2, not 3 ⚠️"]
R0f["Render N: qty = 1"] --> Hf["Handler runs (one click/tap)"]
Hf --> F1["setQty(q => q + 1)"]
Hf --> F2["setQty(q => q + 1)"]
F1 --> Qf["Queued updaters: [q=>q+1, q=>q+1]"]
F2 --> Qf
Qf --> RNf["Re-render once, applied in order 1→2→3: qty = 3 🟢"]
Stale state ⚠️#
Closures (event handlers, setTimeout, effects) capture the state value from the render they were created in. A delayed callback sees the old snapshot:
function Timer() {
const [n, setN] = useState(0);
const start = () => setTimeout(() => setN(n + 1), 3000); // captures n at click time
// fix: setN(prev => prev + 1)
}
Lazy initialization 🟢#
If the initial value is expensive to compute, pass a function so it runs only on the first render, not every render.
// ⚠️ runs on EVERY render, result discarded after first
const [state, setState] = useState(expensiveInit());
// ✅ runs once
const [state, setState] = useState(() => expensiveInit());
Automatic batching (React 18) 🎯#
React 18 batches all state updates in the same tick — including inside promises, setTimeout, and native event handlers — into a single re-render. (Pre-18, only React event handlers batched.) Use flushSync from react-dom to opt out when you need a synchronous DOM update.
async function save() {
setLoading(true);
await api();
setLoading(false); // batched with any sibling updates → one render
setDone(true);
}
Updating objects & arrays immutably ⚠️#
State must be replaced, not mutated — React compares by reference (Object.is) to decide whether to re-render. Mutating the same object means the reference is unchanged, so the UI may not update.
// ❌ mutation — same reference, no re-render
user.name = "Ada"; setUser(user);
todos.push(newTodo); setTodos(todos);
// ✅ new references via spread / array methods
setUser({ ...user, name: "Ada" });
setTodos([...todos, newTodo]);
setTodos(todos.map(t => t.id === id ? { ...t, done: true } : t));
setTodos(todos.filter(t => t.id !== id));
Render cycle#
flowchart LR
A["setState(next)"] --> B["schedule re-render"]
B --> C["React calls component()"]
C --> D["new snapshot of state/props"]
D --> E["diff Virtual DOM"]
E --> F["commit minimal DOM changes"]
Interview Q&A#
Q1. Why does calling setCount(count+1) twice in one handler only add 1?
count is a fixed snapshot for that render, so both calls compute the same value. Use functional updates setCount(c => c+1) to accumulate.
Q2. When should you use the functional updater form? Whenever the next state depends on the previous value, or inside async/delayed callbacks — it reads the latest queued state and avoids stale closures.
Q3. What is lazy initialization and why use it?
Passing a function to useState(() => init()) so the expensive initializer runs only on the first render instead of every render.
Q4. What changed about batching in React 18?
React 18 batches state updates everywhere — including timeouts, promises, and native events — not just inside React event handlers, reducing re-renders. Opt out with flushSync.
Q5. Why won't arr.push(x); setArr(arr) re-render?
It mutates the same array reference. React's Object.is comparison sees no change and skips the update. Create a new array: setArr([...arr, x]).