跪拜 Guibai
← Back to the summary

Closures and useState Aren't Two Topics: You Only Learned Half the Scope Chain

Closures and useState Are Two Knowledge Points? You May Have Learned Only Half the Scope Chain

Summary: Starting from a wrongly answered static-scope output question, this piece connects closures, heap memory, and React's state snapshots into the same chain, and lays out three rules for using useState plus a leak-detection checklist.

Everyone can recite the closure definition in an interview, but after swapping setInterval for useEffect, the same code starts to fail: the counter stays at 1, and clicking does nothing. Most people memorize these as two separate knowledge points, so they learn two sets of conclusions and still cannot answer the follow-up question underneath.

The judgment here: they are two ends of the same chain. Lexical scope decides where variables are looked up, closures decide how long those variables live, and each React render decides which snapshot you get. The source material is a Xiaomi frontend review outline with 4 closure demos and 1 React demo, just enough to walk through the whole chain.

Let's make the boundary explicit: the code snippets come from that outline; all outputs are derived by language rules and were not executed this time, so this counts as 'derived, not run.'

First, look at a three-line counterexample

function bar(){ console.log(myName) }
function foo(){
  var myName = 'jikeban'
  bar()
}
var myName = 'xiaomi'
foo()

This question has a high failure rate in interviews because intuition goes down too smoothly: call bar inside foo, so bar should see foo's myName.

The answer is xiaomi.

bar is written at the outermost global level. Its lookup path starts at that declaration and never passes through foo. The call site is just 'who pressed the switch'; it has nothing to do with 'where the wire is connected.'

This rule carries over to almost any scope question: first find the definition position, then look at the call position.

Misconception 1: a closure is not 'function wrapping function'

Function nesting is only the most common way to write a closure, not the definition.

The real definition: after an inner function is carried outside, it can still access variables in the scope where it was defined. The key is the act of 'being carried out', not 'how many layers are nested.'

Here is the cleanest version:

function foo() {
  var b = 2
  return function bar() { console.log(b) }
}
const baz = foo()
baz()      // derived output: 2

foo has already finished executing. By the rule that contexts are destroyed after use, b should disappear along with it. But bar is returned, held by baz, and still needs b, so b doesn't die.

What dies is the execution context on the stack; what survives is the variable on the heap.

Misconception 2: variables do not 'stay on the stack'; they are moved to the heap

When explaining closures, many people say 'the function is popped off the stack, but the variable is still on the stack.' Mechanically that is wrong: once the stack frame is popped, that stack space is reclaimed, so it cannot keep holding your variable.

What actually happens: the engine sees that bar references b, collects b into a Context object, and puts it on the heap. The stack frame pops as usual; the Context on the heap is not reclaimed by GC because it still has a reference.

The original material compares this to a 'backpack': when the function leaves, it packs the variables it needs into a backpack, and the backpack is placed on the heap, so the person is gone but the bag remains.

This distinction may sound like nitpicking, but it directly determines how you explain memory leaks: closures grow the heap, not the stack. The stack only overflows when recursion goes too deep.

Misconception 3: React takes a new snapshot on every render

With the three points above linked together, React's behavior becomes very natural.

A component is a function. Each render is just another call to that function, producing a fresh set of local variables. The count you write in a particular render is a constant from that moment; it never changes afterward.

An empty dependency array in useEffect means the timer inside is created only once, at mount time. That callback belongs to the first render, and the count it captured is always 0.

useEffect(() => {
  const timer = setInterval(() => {
    setCount(count + 1)              // every second: 0 + 1
  }, 1000)
  return () => clearInterval(timer)
}, [])

Every second it computes 1; React sees no difference between the old and new values and skips re-rendering. The page stays at Count: 1.

The fix only needs a different update style:

setCount(prevCount => prevCount + 1)

prevCount no longer comes from the closure; it is the latest value React hands you from the update queue. The stale-snapshot problem is bypassed.

The App.jsx in the material comments out the wrong line and keeps the fixed line, putting 'wrong evidence' and 'right answer' side by side, which makes it easy to use as a lecture script.

Working backward from that: three rules for useState

No need to memorize the API; these rules follow from the snapshot mechanism.

One, the initial value only takes effect on the first render. So expensive calculations should use lazy initialization with useState(() => heavy()); the function is called only once. Writing useState(heavy()) wastes the computation on every render.

Two, updates are batched. setState only registers a change; React merges the changes into one re-render after the event ends. It is not that 'the value changes later', but that 'the variable in the current render will never change.'

Three, when you depend on the old value, you must use a functional update. Writing setCount(count + 1) three times in a row only adds 1, because all three reads use the same snapshot. Switching to c => c + 1 accumulates them one by one.

To judge whether a closure leaks, look at one thing only

No complex rules needed; just look at where the variable that holds the closure lives:

Holder Lifecycle Risk
Global variable Until the page closes If no longer used, it is a leak
Local variable Can be collected after the function is destroyed Controllable risk

That is the entire criterion. As for claims like 'closures have extra context overhead', that is a performance cost when closures are created at high frequency; it is separate from leakage, so don't mix the two together.

One-page quick reference

Question One-line answer
Where do variables go to be looked up Walk the scope chain along outer; the starting point is where they are defined
Why a closure isn't destroyed Referenced variables are moved to a Context on the heap
Where a closure leaks The heap, not the stack
Difference between this and variable lookup this depends on how it is called; variables depend on where they are written
Counter stuck at 1 The empty-dependency effect captured the first render's snapshot
How to fix it setCount(prev => prev + 1), or put the value into the dependency array
Lazy initialization useState(() => compute()) runs only on the first render

Finally, an executable check

Next time you read any closure code, do this first: find the inner function, circle every external variable it references, and ask yourself: 'Will this function be carried out of the function that defines it?'

If the answer is 'yes', it is a closure and the variable is on the heap. If the answer is 'no', it is just an ordinary nested call and gets collected after use.

This check is more useful than memorizing definitions because it explains three things at once: why b is still there after foo returns, why the counter is stuck at 1, and why a closure held globally keeps occupying memory.

Comments

Top 2 from juejin.cn, machine-translated. The original thread is authoritative.

卷土的土

Functional updates avoid the old snapshot.

ShiliuX

The example of the counter getting stuck at 1 is explained very clearly: the callback in the empty dependency array keeps holding the count from the first render. The functional update line is indeed the most convenient.