Scope, Closures & Hoisting - How JavaScript Remembers
Phase 9 gave you a cheat-card line about hoisting. This phase is the machinery underneath every variable you've written - once you see it, three confusing behaviors stop being magic: why a loop variable inside setTimeout all shows the same number, why let and var behave differently in blocks, and the big one - closures, the feature powering callbacks, event handlers, React hooks, and the module pattern.
The whole phase rests on one question: when JavaScript sees a variable name, where does it look for the value? Answer that and everything else falls out of it.
Scope and the scope chain - where names are looked up
What it actually is. Scope is the set of variables visible from a given spot in your code. Every function creates a new scope, nested inside the scope it was written in. JavaScript looks for a variable in the current scope first; if it's not there, it checks the scope outside that one, then the one outside that, all the way to global. That outward chain of lookups is the scope chain.
📝 Lexical scope - "lexical" means where you wrote it. A function's scope is decided by its physical position in the source code, not by where or how it's later called - JavaScript wires up the scope chain by reading your file, before anything runs.
One idea: a name lookup only ever travels outward, never inward. inner can reach into outer and the global scope, but nothing outside inner can see inner's variables. Watch the chain resolve a name:
const = ; // global scope
;
42 Ada Manual
What just happened: Inside inner, JavaScript found secret locally. It didn't find name, so it stepped out to outer's scope - found it. Same for appName, one level further out. Three names, resolved at three links of the chain, all by walking outward.
⚠️ Gotcha - lookups go out, not in. Reading secret from inside outer (but outside inner) throws a ReferenceError. A scope is a one-way mirror: the inside sees out, the outside can't see in - why ten different functions can each have their own name variable without colliding.
var vs let/const - function scope vs block scope
Here's the first place the rules diverge, and it's the source of a famous bug: the unit of scope each keyword respects.
📝 Block scope - a "block" is any { ... }: an if, a for, a bare pair of braces. let and const are confined to the block they're declared in. Function scope - var ignores blocks entirely; it's visible throughout the entire function it lives in, no matter how deeply nested.
;
I'm stuck in this if-block
I'm everywhere in test()
undefined
What just happened: var leaks was declared inside the if block, but var ignores blocks - it belongs to the whole function, so it's readable after the if closes. let trapped obeys the block: outside it, typeof trapped reports "undefined". That leak is exactly why var causes trouble.
The classic loop bug. You loop, schedule some callbacks, and expect them to print 0, 1, 2. With var, they all print 3:
const = ;
; // [3, 3, 3]
const = ;
; // [0, 1, 2]
[ 3, 3, 3 ]
[ 0, 1, 2 ]
What just happened: With var i, there's exactly one i for the whole loop - function-scoped, shared by all three callbacks. By the time they run, the loop has finished and that single i is 3. With let j, JavaScript creates a fresh j for each iteration - three separate variables, each captured with the value it had that round. This is the headline reason to default to let/const and treat var as legacy.
💡 Key insight. let/const being block-scoped isn't just tidier - it makes loops with closures do what you mean. The per-iteration binding is the whole fix; reach for var essentially never.
Hoisting, properly - declarations come first
Before running a single line of a scope, JavaScript does a quick first pass: it finds all the declarations and sets them up. That is hoisting - declarations are processed before execution begins, as if lifted to the top of their scope. But "hoisting" doesn't mean every keyword behaves the same - there are three distinct behaviors.
Function declarations are fully hoisted - name and body are available before its line, so you can call it above where it's written.
var is hoisted but left undefined - the name is set up early (so using it isn't a ReferenceError), but its value isn't assigned until the line runs. Read it early and you get undefined.
let and const are hoisted into the Temporal Dead Zone - the name is reserved, but touching it before its declaration line throws.
📝 Temporal Dead Zone (TDZ) - the stretch from the start of a scope until a let/const declaration actually runs. The variable exists (the name is reserved) but is off-limits; reading it throws ReferenceError: Cannot access 'x' before initialization. It's JavaScript protecting you from using a value that isn't ready yet.
; // 5 - function declaration is fully hoisted
; // undefined - var name exists, value doesn't yet
var = ;
5
undefined
TDZ error: Cannot access 'strict' before initialization
What just happened: addUp ran before its definition because function declarations are hoisted whole. maybe printed undefined since var reserved the name but hadn't reached the assignment. strict threw, because let is hoisted only enough to know it exists. The habit that sidesteps all of this: declare before you use, and prefer const.
⚠️ Gotcha - the TDZ is a feature, not a bug. It's catching a real mistake: using a value the code hasn't computed yet. var makes that same mistake silent - undefined, with the bug surfacing far away. The TDZ fails loud and early, at the source.
Closures - a function remembers where it was born
A closure is what you get when a function outlives the scope it was created in, and keeps access to that scope's variables anyway - it carries its birthplace with it.
📝 Closure - a function bundled together with the variables from the scope where it was defined. Even long after its outer function has returned, it can still read and update those captured variables - it remembers where it was born, not where it's called.
The counter factory is the canonical example:
const = ; // makeCounter has now RETURNED
; // 1
; // 2
; // 3
const = ; // a brand-new, independent count
; // 1
1
2
3
1
What just happened: makeCounter ran, created count, and returned the inner function - then it was done. Normally count would vanish with it, but the returned function still references count, so JavaScript keeps it alive as long as the function exists. Each call reaches outward to the same count and bumps it. fresh is a separate call, so it gets its own count starting at 1 - closures don't share state across separate creations.
💡 The mental model. The function doesn't copy count - it holds a live link back to the exact scope it was born in. As long as the function is reachable, that scope stays alive: a closure is a function plus a backpack of the variables it grew up with.
A real use - true data privacy. Those captured variables aren't reachable from outside the closure - there's no counter.count to poke at, since the only way to touch count is through the function. That's genuinely private state, something JavaScript otherwise lacks:
const = ;
; // runs the body
; // skips it - returns the cached result
; // still cached
running expensive setup...
ready
ready
ready
What just happened: once returns a wrapper closing over the private flag called and the cached result. The first call flips called and runs the real function; every call after sees called === true and returns the stored result without re-running. Nothing outside can reset called - it's sealed inside the closure. This pattern guards one-time initialization, single payment submissions, and "load it once" caches everywhere.
Why it matters - closures are everywhere
You've been using closures since Phase 4 without naming them. Now you can see them:
- Callbacks and event handlers.
button.addEventListener("click", () => doThing(config))closes overconfig- it still works whenever the click fires, minutes later, because the closure keptconfigalive. - The module pattern. Wrapping code in a function and returning only what you want public is the closure-powered way to get private variables - how libraries hid internals before
import/export. - React hooks.
useStateand friends lean entirely on closures - each render's functions close over that render's values. (The loop-variable bug above is the #1 source of "stale closure" confusion in React.) - The cost. A closure keeps its captured variables alive as long as the function lives - usually what you want, but a long-lived closure (an event handler you never remove) quietly keeps everything it captured in memory. Remove handlers you no longer need.
The thread tying the phase together: scope is decided by where you write code, hoisting decides what's ready when execution starts, and closures are scope outliving the function that created it.
Recap
- Scope is the set of visible variables; a name lookup walks the scope chain outward - local, then enclosing, then global - and never inward.
- Lexical scope means scope is fixed by where code is written, not where it's called - JavaScript wires up the chain by reading the source.
varis function-scoped (it leaks out of blocks);let/constare block-scoped. The per-iteration binding ofletis what fixes the classic[3, 3, 3]loop-in-callback bug.- Hoisting processes declarations first: functions are fully hoisted,
varis hoisted-but-undefined, andlet/constsit in the Temporal Dead Zone until their line runs. - A closure is a function plus the variables from its birthplace; it keeps those variables alive after the outer function returns, giving you private state and persistent counters.
- Closures power callbacks, event handlers, the module pattern, and React hooks - but a long-lived closure holds its captured variables in memory, so release handlers you no longer need.
Quick check
Test yourself on where a function looks for its variables:
[
{
"q": "Why do the `var` callbacks print `[3, 3, 3]` while the `let` callbacks print `[0, 1, 2]`?",
"choices": [
"`var` creates one shared, function-scoped `i` for the whole loop; `let` creates a fresh, block-scoped binding each iteration",
"`let` callbacks run before the loop finishes, but `var` callbacks run after",
"`var` rounds its values up to the final count, while `let` preserves them",
"Arrow functions only capture `let` variables, never `var` variables"
],
"answer": 0,
"explain": "There's exactly one function-scoped `i` with `var`, shared by all three callbacks - by the time they run, it's 3. `let` gives each iteration its own binding, so each callback captures the value it had that round."
},
{
"q": "What is the Temporal Dead Zone?",
"choices": [
"The window from the start of a scope until a `let`/`const` line runs, during which accessing the variable throws",
"A performance penalty for declaring too many variables in one function",
"The time it takes a closure to release its captured variables from memory",
"A region of code where `var` declarations are silently ignored"
],
"answer": 0,
"explain": "`let`/`const` are hoisted enough that the name is reserved, but reading them before their declaration line throws `ReferenceError: Cannot access 'x' before initialization`. That stretch is the TDZ - it fails loud instead of giving you a silent `undefined`."
},
{
"q": "After `const counter = makeCounter()` returns, why can the returned function still increment `count`?",
"choices": [
"The returned function is a closure - it holds a live link to the scope where it was defined, keeping `count` alive",
"`count` was copied into the returned function as a private constant",
"JavaScript re-runs `makeCounter` on every call to rebuild `count`",
"`count` was secretly promoted to a global variable when makeCounter returned"
],
"answer": 0,
"explain": "A closure keeps the variables from its birthplace alive as long as the function exists. The returned function references `count`, so that scope isn't discarded - each call reaches the same live `count` and bumps it. A separate `makeCounter()` call gets its own independent `count`."
}
]
← Phase 9: Idioms & Common Gotchas · Guide overview · Phase 11: this, Prototypes & the Object Model →
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. Why do the `var` callbacks print `[3, 3, 3]` while the `let` callbacks print `[0, 1, 2]`?
2. What is the Temporal Dead Zone?
3. After `const counter = makeCounter()` returns, why can the returned function still increment `count`?