DeepFrontend
Practice ArenaBlogSign in
Go Pro
DeepFrontend

Interview-grade learning paths and hands-on practice for mid-to-senior engineers. Master internals, patterns, and system architecture with runnable code.

All systems operational

Core Courses

  • -JavaScript Internals
  • -React Reconciliation
  • -TypeScript rigor
  • -Next.js Caching
  • -Node.js Event Loop
  • -System Design
  • -Web Security
  • -CSS & Page Layouts

Explore

  • Learning Paths
  • Practice Arena
  • Pricing Options
  • HTML Sitemap

Resources

  • Privacy Policy
  • Terms of Service
  • Email Support

© 2026 DeepFrontend. All rights reserved.

Expert learning environments for web engineering teams.

Home/React/State
beginner20 min read·Updated Jun 2025

State

Calling a state setter doesn't change a variable — it schedules React to call your component function again, from scratch, with a new value in its place. Everything confusing about state (stale closures, batching, 'why didn't it update immediately') follows from that one fact.

stateusestatebatchingre-renderfundamentals

Knowledge Check

4 questions · pass at 70%

0/4
easypredict output

1. What does this log when the button is clicked?

code
function Counter() {
  const [count, setCount] = useState(0);
  function handleClick() {
    setCount(count + 1);
    console.log(count);
  }
  return <button onClick={handleClick}>{count}</button>;
}

Interview Questions

2 questions

Cheatsheet

Download-ready reference
Cheatsheet
const [value, setValue] = useState(initial);

Calling setValue does NOT mutate `value` in this render.
  It SCHEDULES a future re-render where the component gets a NEW `value`.

setValue(x + 1)          → computed NOW, using THIS render's stale x — risky if called
                            multiple times or depended on elsewhere before re-render
setValue(prev => prev+1) → applied LATER against the true latest value — always safe
                            when new state depends on old state

React 18+: multiple setState calls in one handler = ONE batched re-render,
  not one render per call.

NEVER mutate state directly:
  items.push(x); setItems(items);   ❌ same reference — may not re-render
  setItems([...items, x]);           ✅ new reference — React detects the change

Stale closures: every render's event handlers close over THAT render's state,
  permanently. A setTimeout/callback from an old render still sees old state.

Related topics

PropsRendering LifecyclePro

On this page

20m
Knowledge CheckInterview QuestionsCheatsheet

Concept#

The beginner framing: state is data a component owns that can change over time — when it changes, the UI showing it updates automatically.

The precise mental model: useState doesn't give you a mutable variable — it gives you a value frozen for this specific render, plus a setter function that, when called, tells React "please re-run this component function again, and this time give it this new value instead." Calling the setter does not change anything about the current, already-running render — it schedules a future call to your component function.

function Counter() {
  const [count, setCount] = useState(0);
 
  function handleClick() {
    setCount(count + 1);
    console.log(count); // still logs the OLD value — this render's `count` never changes
  }
 
  return <button onClick={handleClick}>{count}</button>;
}

Nothing about count inside this specific call to Counter ever changes — it's a plain const. Clicking the button schedules an entirely new call to Counter(), and that call receives the incremented value as its own, brand-new count. This is why console.log(count) right after calling setCount still shows the old value: you're still inside the render that scheduled the update, not the one that received it.

State update cascades to every child, by default
live — not a recording
Parentrendered 1×
state: count = 0
ChildA — no propsrendered 1×
ChildB — no propsrendered 1×

Click the button a few times. Notice ChildA and ChildB flash and their "rendered N×" counters climb too — even though neither ever received a new prop. React re-renders a component's entire subtree by default whenever its state changes; nothing opts a child out automatically.

Batching: multiple state updates, one re-render#

function handleClick() {
  setCount(count + 1);
  setFlag(true);
  // React 18+: both updates are BATCHED — only ONE re-render happens,
  // with both new values applied together, not two separate renders.
}

Since React 18, updates are automatically batched in virtually every context (event handlers, promises, timeouts, native event listeners) — calling multiple setters in a row schedules exactly one re-render reflecting all of them, not one render per call. This is a performance optimization, but it also means you should never rely on a re-render happening synchronously between two setter calls in the same function.

Functional updates: when new state depends on old state#

setCount(count + 1); // reads `count` from THIS render's closure — safe once, risky if called multiple times
setCount((prev) => prev + 1); // reads whatever the LATEST pending value actually is — always correct
function handleTripleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
  // Result: count goes up by only 1! All three calls close over the SAME
  // `count` from this render — each one computes "current + 1" independently.
}
 
function handleTripleClickFixed() {
  setCount((prev) => prev + 1);
  setCount



The functional form exists specifically to avoid this — reach for it whenever a new state value is computed from the previous one.


Try It#

Predict what the count is after one click, before checking the solution.

function Counter() {
  const [count, setCount] = useState(0);
 
  function handleClick() {
    setTimeout(() => {
      setCount(count + 1);
    }, 1000);
  }
 
  return <button onClick={handleClick

Solution

The count ends up at 1, not 3 — even though the button was clicked three times.

Each click's setTimeout callback is a closure over that specific render's count, which was 0 for all three clicks (since none of the renders had completed yet within that first second). All three timeouts fire with setCount(0 + 1) — three calls that all compute the same result, not three cumulative increments. This is the exact same stale-closure trap as the synchronous triple-click example above, just with a delay added. The fix is identical: setCount((prev) => prev + 1) in the timeout callback would correctly reach 3.


Implement It Yourself#

Build a radically simplified useState to see the "slot" mechanism hooks actually use — a classic interview exercise:

let hookStates = [];
let hookIndex = 0;
 
function useState(initialValue) {
  const currentIndex = hookIndex; // capture THIS hook's slot before any nested calls change it
  if (hookStates[currentIndex] === undefined) {
    hookStates[currentIndex] = initialValue;
  }
 
  function setState(newValue) {
    hookStates[currentIndex] 




















This is a genuinely close approximation of how React's hooks work internally: state lives in an array outside your component (attached to the underlying fiber, not to a local variable), indexed by call order, and every re-render walks that array from the beginning — which is exactly why hooks must always be called in the same order, every render, with no conditionals around them.


Under the Hood#

Every render is a fresh call to your component function (Execution Context) — which means every const [count, setCount] = useState(0) line runs again from scratch, creating a fresh count binding and a fresh setCount closure each time. The reason the stale-closure bugs above happen at all is Closures working exactly as designed: handleClick's inner functions close over that specific render's count binding, permanently — there is no shared, mutable "count" variable anywhere for them to read a later value from. React re-runs your function to get a new closure with a new value; it doesn't (and structurally can't) reach back in and mutate an old one.


Common Mistakes#

1. Mutating state directly instead of calling the setter#

const [items, setItems] = useState([]);
items.push(newItem);       // ❌ mutates the array — React never finds out anything changed
setItems(items);           // same reference as before — React's Object.is check sees NO change, skips re-render
setItems([...items, newItem]); // ✅ a genuinely NEW array reference — React detects the change

React decides whether to re-render (for a given piece of state) partly by checking whether the new value is reference-different from the old one. Mutating in place keeps the same reference, so even if you call the setter, React may conclude nothing changed.

2. Assuming setState updates the variable synchronously#

function handleClick() {
  setCount(count + 1);
  console.log(count); // ❌ still the OLD value — this render's `count` is a const, never reassigned
}

Covered above — the setter schedules a future render; it never mutates the current render's binding.

3. Forgetting the functional-update form when depending on the previous value#

Covered above — multiple setCount(count + 1) calls in the same handler don't stack, because each one independently reads the same stale count. Use setCount(prev => prev + 1) whenever the new value depends on the old one.


Best Practices#

  • Use the functional update form (prev => ...) whenever new state depends on the current state — it's never wrong, even in the simple case, and it's the only correct option when multiple updates might batch together.
  • Never mutate state values in place — always create a new array/object ([...arr], {...obj}) so React can detect the change by reference.
  • Keep state minimal — if a value can be computed from existing state or props during render, don't also store it as separate state; derive it instead, so there's only one source of truth to keep in sync.
  • Colocate state as close as possible to where it's used — lifting everything to a top-level component "just in case" causes far more of the tree to re-render than necessary (see Rendering Lifecycle).

Performance Tips#

  • Every setState call (that actually changes the value) triggers a re-render of that component and, by default, its entire subtree — see the live demo above. Keeping state colocated close to where it's needed limits the size of the subtree that re-renders on every update.
  • Storing something as state when it could be derived during render (e.g., a filteredItems state kept "in sync" with a items state via an effect) is a common source of both bugs (two sources of truth drifting out of sync) and unnecessary re-renders (the effect itself causes an extra render). Compute it directly in the render body instead: const filteredItems = items.filter(...).
  • React's Object.is reference check on state updates is why the mutation mistake above isn't just incorrect — it can also make a React.memo'd child think nothing changed and skip a re-render it actually needed.

((
prev
)
=>
prev
+
1
);
setCount((prev) => prev + 1);
// Result: count goes up by 3 — each functional update receives the
// PREVIOUS update's result, not this render's stale snapshot.
}
}
>
{
count
}
</
button
>;
}
// user clicks the button 3 times, quickly, all within one second
=
typeof newValue === "function" ? newValue(hookStates[currentIndex]) : newValue;
render(); // re-run the component from the top
}
hookIndex++;
return [hookStates[currentIndex], setState];
}
function render() {
hookIndex = 0; // reset to slot 0 at the START of every render — this is why
// hook call ORDER must never change between renders (see Hooks Overview)
Component();
}
function Component() {
const [count, setCount] = useState(0);
console.log("count:", count);
// somewhere: setCount(c => c + 1) would trigger another render() call
}
render(); // count: 0