Scope, hoisting, and closures
var against let and const, what hoisting actually moves, and the loop variable trap.
After this lesson you can
- Explain why var leaks out of a block and let does not
- Say what hoisting moves and what it leaves behind
- Fix the classic closure-in-a-loop bug two different ways
var is scoped to the nearest function, not the nearest block. let and
const are scoped to the nearest block — a { }, an if, a for.
if (true) {
var a = 1;
let b = 2;
}
console.log(a); // 1 — var leaked out of the if
console.log(b); // ReferenceError — b never existed out here
There is close to no reason to reach for var in code written today.
const by default, let when the binding is genuinely reassigned.
Hoisting
A declaration is processed before any code in its scope runs — that is hoisting. What moves, and what it is initialised to, differs by keyword:
console.log(x); // undefined, not an error — the declaration hoisted
var x = 5;
console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 5;
var hoists the declaration and initialises it to undefined
immediately. let and const hoist the declaration but leave it
uninitialised until the line that assigns it runs — reading it before
that point throws, in what is called the temporal dead zone. let's
behaviour is the useful one: an error at the exact point of the mistake
beats a silent undefined three lines later.
Function declarations hoist their entire body, which is why you can call
a function defined further down the file. Function expressions
(const f = function () {}) do not — only the const f binding hoists,
not what it points to.
Closures
A closure is a function that remembers the variables from where it was defined, even after that scope has finished running.
function makeCounter() {
let count = 0;
return () => ++count;
}
const counter = makeCounter();
counter(); // 1
counter(); // 2 — the same `count`, still alive
count is not reset between calls: makeCounter returns one specific
function that keeps its own reference to that one specific variable.
Try it
export function run() { const fns = []; for (let i = 0; i < 3; i++) { fns.push(() => i); } return fns.map((f) => f());}The loop variable trap
This is the closure question that actually comes up:
const fns = [];
for (var i = 0; i < 3; i++) {
fns.push(() => i);
}
fns.map((f) => f()); // [3, 3, 3] — everyone shares the same i
var i is one binding for the whole loop. Every closure captured a
reference to that single variable, and by the time any of them run the
loop has already finished with i at 3. Two fixes:
for (let i = 0; i < 3; i++) { fns.push(() => i); } // [0, 1, 2]
let creates a fresh binding per iteration, so each closure captures
its own i. The other fix, for anywhere var cannot be avoided, is an
IIFE that copies the value into a new scope on purpose — let made that
unnecessary, which is most of why it exists.
Try it yourself
2 visible tests · 2 hidden testsImplement delayedValues(items). Build one closure per item that
returns that item, collect all the closures first, and only then call
every one of them and return the results, in order. The point is the
order of operations — build all the closures, then run them — which is
exactly the shape that exposes the classic loop-variable bug if you get
the declaration wrong.
delayedValues(["a","b","c"])delayedValues([42])
Sign up to check the hidden tests and save your progress. Sign up