Topics in this subject
React 5 min read Updated 6 Aug 2026

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]).