Scope, Hoisting & Closures: How JavaScript Sees Your Variables
Why can a function remember variables after its parent has returned? Master lexical scope, hoisting, the TDZ, and closures — the concept behind every callback, module, and React hook.
4 sections · ~25 min · 5-question quiz (pass ≥ 70%)
1Lexical Scope: Where Variables Live
JavaScript uses lexical (static) scope: where you write a variable determines where it's visible, not where or when the code runs.
const outer = "I'm outside";
function parent() {
const middle = "I'm in parent";
function child() {
const inner = "I'm in child";
console.log(outer, middle, inner); // all visible here
}
child();
// console.log(inner); // ReferenceError — inner scopes aren't visible outward
}
Scopes nest like one-way mirrors: inner code sees outward; outer code cannot see in.
let and const are block-scoped (visible only inside the nearest { }), while var is function-scoped — it leaks out of if blocks and loops, which is a major reason it's avoided.
2Hoisting and the Temporal Dead Zone
Before executing your code, the engine registers declarations. This is hoisting — but different declarations hoist differently:
sayHi(); // "Hi!" — works: function declarations hoist fully
function sayHi() { console.log("Hi!"); }
console.log(x); // undefined — var hoists, initialized to undefined
var x = 5;
console.log(y); // ReferenceError! — let hoists but is uninitialized
let y = 5;
The zone between entering a scope and the let/const declaration line is the Temporal Dead Zone (TDZ) — the variable exists but touching it throws.
This is a feature, not a bug: the TDZ turns "silently got undefined" bugs into loud, immediate errors.
3Closures: Functions That Remember
A closure is a function bundled with the scope it was created in. The function keeps that scope alive — even after the outer function has returned.
function makeCounter() {
let count = 0; // lives in makeCounter's scope
return function () {
count++; // still accessible!
return count;
};
}
const counter = makeCounter(); // makeCounter has returned...
counter(); // 1
counter(); // 2 — count survives between calls
const other = makeCounter();
other(); // 1 — each call to makeCounter creates a fresh scope
Key insights:
- The inner function closes over the variable itself, not a snapshot of its value.
- Each invocation of the outer function creates an independent closure.
- This is JavaScript's original mechanism for private state — no class needed.
4Closures in the Wild (and One Classic Trap)
Closures power patterns you use every day:
// Module pattern: public API over private state
function createWallet() {
let balance = 0; // private — nothing outside can touch it
return {
deposit(amount) { balance += amount; },
getBalance() { return balance; },
};
}
// Event handlers & callbacks remember their context
function setupButton(label) {
button.addEventListener("click", () => console.log(`${label} clicked`));
}
The classic loop trap:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i)); // 3, 3, 3 — all share ONE i
}
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i)); // 0, 1, 2 — let gives each iteration its own i
}
With var there is a single function-scoped i that ends at 3 before any timeout runs. let creates a fresh binding per iteration — each closure captures its own.