How useState Remembers Values: Mastering Closures and Execution Context

If you are a React developer, there is a line of code you probably write dozens of times a day:
const [count, setCount] = useState(0);We take it for granted that when we call setCount, the count increases, and the value is maintained even when the component re-renders.
But wait a minute. If you think about the basic operating principles of JavaScript, this borders on magic. A functional component is, literally, a function. When a function is called again (re-rendered), its internal variables should technically be initialized.
So, how on earth does useState "remember" the value even after the function has finished executing?
The secret lies in Closure, a core concept of JavaScript.
Functions Have Amnesia
Let's first look at the execution process of a JavaScript function. Chris created a simple counter function.
// bad-counter.js
function Counter() {
let count = 0; // Local variable
count += 1;
return count;
}
console.log(Counter()); // 1
console.log(Counter()); // 1 (Huh? Shouldn't this be 2?)No matter how many times you call it, the result is always 1.
The reason is the Execution Context. When a function is called, an execution context is created, and when the function finishes, this context—along with its internal variables (count)—is removed from memory (Garbage Collected). In other words, functions are inherently "unable to maintain state."
Time Capsule for Memories: Closures
To solve this problem, JavaScript provides a feature called Closure.
According to MDN, a closure is "the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment)." That sounds complicated. To put it simply, it's like "packing a backpack with the external variables present when the function was created and carrying it around."
Now, Chris changed the code slightly.
// closure-counter.js
function createCounter() {
let count = 0; // Variable of the outer function (Free Variable)
return function() {
count += 1; // Inner function references the outer variable
return count;
};
}
const counter = createCounter(); // Receive the inner function
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3The createCounter function has finished executing, but the returned inner function can still access the count variable. Even if the outer function dies (finishes), if the inner function stays alive, the variables it references do not die. This is the core of closures.
Implementing a React Hook Yourself
React's useState uses this exact principle. Inside the React module, there is a "real variable storage" that we don't see, and useState is a closure that can access that storage.
Let's build a very simplified MyReact.
// MyReact.js
const MyReact = (function() {
let _val; // 1. Variable outside the function (State Storage)
function useState(initialValue) {
// If no value, assign initial value (First render)
const state = _val !== undefined ? _val : initialValue;
// 2. setState is a closure that modifies _val
const setState = (newValue) => {
_val = newValue;
render(); // Trigger re-render when state changes
};
return [state, setState];
}
function render() {
console.log('Rendered.');
}
return { useState, render };
})();
// Usage Example
const [count, setCount] = MyReact.useState(0); // count: 0
setCount(1); // _val updates -> 1
const [count2, setCount2] = MyReact.useState(0); // count2: 1 (It remembers!)MyReact is executed as an IIFE (Immediately Invoked Function Expression), hiding the _val variable in the closure space.
Whenever we call useState in a component, this function references the _val sitting outside itself to return or modify the value.
Even if the component function (Counter) is re-rendered and called anew, the state is maintained because _val inside the MyReact module remains alive.
Note: Actual React manages multiple useState hooks using an array and an index (hookIndex), not just a single variable (_val). This is why we must follow the rule: "Hooks must be called at the top level and in the same order."
Caution: Stale Closures
Closures are powerful, but because they remember "the environment at the time of creation," they can cause mistakes. This is the Stale Closure problem that plagues React developers.
// stale-example.js
function Component() {
const [count, setCount] = useState(0);
useEffect(() => {
// This function is a closure remembering 'count' (0) from the 'first render'
const timer = setInterval(() => {
console.log(count); // Only prints 0 repeatedly!
}, 1000);
return () => clearInterval(timer);
}, []); // Empty dependency array
// ...
}The callback function inside setInterval was created when the component was first rendered. At that time, count was 0. Even if count increases to 1, 2, or 3 later, the count inside the "backpack" of this callback function is still 0.
To fix this, you need to add count to the dependency array to recreate the closure, or use a functional update (setCount(prev => prev + 1)).
Key Takeaways
useState is not magic, but a pattern utilizing JavaScript's fundamental principle: Closures.
Next time, based on this principle, let's understand the process of how the browser draws the screen—the rendering pipeline. It is the first step toward performance optimization.
🔗 References
Next Post Teaser:
Now that we understand JavaScript and memory structures, we need to learn how the browser turns this code into pixels on the screen.
Continuing in: "Browser Rendering Principles: Understanding Reflow and Repaint is Key to Optimization"