beginner22 min

State: Giving Components Memory

Why a plain variable doesn't survive a re-render, and what useState actually does underneath — built by hand, once, so it stops feeling like magic.

What you'll learn

  • Explain why a plain local variable can't hold state across renders
  • Explain what useState conceptually does using a closure-based re-implementation
  • Use the functional-update form of a state setter correctly when the new value depends on the old one

Prerequisites

Explanation

A component is a function, and every re-render is React calling that function again from scratch. A plain let count = 0; declared inside a component gets reset to 0 on every single call — it cannot remember anything between renders, because nothing about calling a function again preserves its local variables from the previous call. This is exactly the problem useState exists to solve: it gives a component memory that survives across its own re-invocations, stored by React outside the function itself.

You don't need to take that on faith. A closure-based re-implementation makes it concrete: React (conceptually) keeps an array of "state slots" per component instance, and each call to useState claims the next slot in that array, in the same order every render — which is exactly why the Rules of Hooks forbid calling useState conditionally: if a hook call is sometimes skipped, every subsequent hook call shifts to the wrong slot, silently reading and writing the wrong piece of state.

Calling the setter doesn't mutate the current render's variable — it schedules a re-render with the new value for next time. const [count, setCount] = useState(0); setCount(count + 1); reads count as it was at the start of this render and schedules the next render with the new value; count itself, in the render that just ran, never changes.

This distinction matters most when a new state value depends on the previous one, especially across multiple updates queued close together. setCount(count + 1) twice in a row, in the same event handler, both read the same count from that render's closure — you get +1, not +2. The fix is the functional update form: setCount(prev => prev + 1), which always receives the truly-latest value React has, regardless of how many updates are queued. This is not a minor style preference; it's the difference between a correct counter and a subtly, intermittently broken one.

Example

A real, closure-based re-implementation of useState's core idea -- state stored outside the component function, surviving each new call.

function createStateSlot(initialValue) {
  let value = initialValue;
  function get() {
    return value;
  }
  function set(newValueOrUpdater) {
    value = typeof newValueOrUpdater === "function" ? newValueOrUpdater(value) : newValueOrUpdater;
  }
  return [get, set];
}

const [getCount, setCount] = createStateSlot(0);
console.log(getCount()); // 0 -- survives because it lives outside the "component" call
setCount(getCount() + 1);
console.log(getCount()); // 1

Try it yourself

Call setCount twice in a row using the DIRECT (non-functional) form, then check the final count -- is it what you expected?

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Guided exercise

Guided exercise

Using createStateSlot already defined, fix the double-increment bug from the example by using the FUNCTIONAL update form both times, storing the final value in finalCount (should be 2).

Checks: reaches the correct final count using functional updates

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Stuck? Get a hint.

Independent exercise

Independent exercise

Write a function claimNextSlot(slotIndexRef) that models the Rules of Hooks: it takes an object { current: number } representing 'the next slot index for this render', returns the current value, and increments slotIndexRef.current by 1 -- modeling how each useState call claims the next sequential slot.

Checks: claims sequential slots in order · advances the counter correctly

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Stuck? Get a hint.

Common mistakes

  • Using a plain `let` variable for something that needs to survive a re-render, then being confused when it keeps resetting.
  • Calling the state setter with a direct value that depends on the current state (`setCount(count + 1)`) more than once in the same handler, expecting each call to see the previous call's update.
  • Calling useState conditionally (inside an `if`), which shifts every subsequent hook call to the wrong slot in React's real implementation.

Knowledge check

Knowledge check

1. Why can't a plain `let` variable inside a component hold state across renders?
2. Why does calling `setCount(count + 1)` twice in the same handler often not produce +2?
3. Why do the Rules of Hooks forbid calling useState conditionally?

Takeaway

useState gives a component memory that survives its own re-invocation by storing state outside the function — and the functional update form exists specifically to avoid reading a stale value when an update depends on the previous state.

Summary

This lesson built a closure-based re-implementation of useState's core idea to explain why plain variables can't hold state, why hook call order matters, and when the functional update form is required.

References

Your notes

Notes save automatically.

Finished this lesson?

Mark it complete to track your progress and schedule a future review.