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

State & useState

useState gives per-render snapshots; use functional updates, lazy init, batching, and immutable updates to avoid stale-state bugs.

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.

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