跪拜 Guibai
← All articles
Frontend

Why setTimeout Sees Stale Values: Five Closure Traps Every JavaScript Dev Hits

By 前端阿凡 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Async callbacks that read stale or unintended values cause bugs that pass code review because the code looks correct line by line. Knowing whether a closure captures a snapshot or a live reference determines whether a timer, event handler, or React effect behaves predictably under state changes.

Summary

A `for` loop with `var` prints the final value three times because the loop shares a single function-scoped binding. Switching to `let` gives each iteration its own block-scoped binding, fixing the output. The same mechanism causes stale state in React when `useEffect` or `useCallback` closes over a prop or state value without listing it in the dependency array.

Object properties accessed inside a callback are never snapshots; they resolve to whatever the property holds when the callback finally runs. To freeze a value, destructure it into a separate variable before the timer. When the goal is to read the latest value deliberately, keep the mutable reference alive in an outer scope.

Five concrete patterns cover the whole problem space: the `var` loop trap, IIFE-based value capture, intentional late reads, mutable object properties, and React's stale-closure bug with functional updates as the fix.

Takeaways
Closures capture a variable's scope, not its value at definition time; a `setTimeout` callback always reads the variable's current state when it executes.
`var` in a `for` loop shares one binding across all iterations, so every queued callback sees the final loop value.
`let` creates a fresh binding per iteration, making each callback see its own iteration's value.
An IIFE or a function parameter freezes the current value by passing it into a new scope, preventing later mutations from affecting the callback.
Object properties read inside a callback are live; to get a snapshot, destructure the value into a separate variable before the timer.
In React, a `useEffect` or `useCallback` with an empty dependency array closes over the initial render's state, causing stale values; functional state updates (`c => c + 1`) avoid the dependency entirely.
Conclusions

The distinction between capturing a reference and capturing a value is not a JavaScript quirk but a direct consequence of lexical scoping, and it trips up developers in every language with first-class closures and async execution.

React's stale-closure problem is the same `var`-loop trap in a different costume: a function holds onto a binding from a previous execution context, and the fix is either a new binding per render or a functional update that reads the latest state from the runtime.

The article's framing of 'snapshot vs. latest value' as an explicit design choice is more useful than treating one behavior as correct and the other as a bug; both are needed in different situations.

Concepts & terms
Closure
A function that retains access to variables from its outer lexical scope even after that scope has finished executing. The closure holds a reference to the variable itself, not a copy of its value at creation time.
Block scope (let/const)
`let` and `const` bind variables to the nearest enclosing block (`{}`), including loop bodies. Each iteration of a `for` loop with `let` gets a fresh binding, unlike `var` which is scoped to the entire function.
IIFE (Immediately Invoked Function Expression)
A function defined and called immediately, creating a new scope. Used to capture the current value of a variable by passing it as an argument, freezing it against later changes.
Stale closure (React)
A callback inside `useEffect` or `useCallback` that references state or props from an old render because those values were not included in the dependency array, causing the callback to operate on outdated data.
Functional state update
Passing a function (`prev => prev + 1`) to a React state setter instead of a raw value. React guarantees the function receives the most recent state, eliminating the need to list the state as a dependency.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗