Lesson 17 of 28
Scope and Closures
The scope chain, the loop-with-var bug that let quietly fixes, and closures explained properly as functions that remember the variables they were created beside.
Two bugs bring most people to this lesson. The first is a loop that creates
three functions and finds all three report the same number. The second is a
variable that reads as undefined even though the declaration is right there
in the file. Both are scope. Closures are what you get once scope makes sense.
How a name gets resolved
When you use a name, JavaScript looks for it in the current block. If it is not
there it looks in the block outside that, then the function, then the module,
then the global scope. The first match wins. If nothing matches you get a
ReferenceError.
const siteName = "AniUI Academy"; // module scope
function renderLesson(lesson) {
const heading = lesson.title; // function scope
if (lesson.published) {
const badge = "Live"; // block scope
console.log(badge, heading, siteName); // all three resolve
}
console.log(badge); // ReferenceError: badge is not defined
}That walk outward is the scope chain. It only goes outward, never inward, which
is why badge is invisible one line after the if block ends.
When the same name exists at two levels, the inner one shadows the outer. That is legal and often deliberate, but it is also how you end up reading the wrong variable in a long function.
var scopes to the function, let scopes to the block
let and const respect { }. var does not — it escapes to the nearest
function.
function summarise(scores) {
if (scores.length > 0) {
var best = Math.max(...scores);
let worst = Math.min(...scores);
}
console.log(best); // works — var leaked out of the block
console.log(worst); // ReferenceError
}This looks harmless until a loop is involved.
const handlers = [];
for (var i = 0; i < 3; i++) {
handlers.push(() => console.log("row", i));
}
handlers.forEach((run) => run());
// row 3
// row 3
// row 3There is only one i for the entire function. All three functions point at it.
By the time they run, the loop has finished and i is 3. Swap var for
let and the loop creates a fresh binding on every iteration, so each function
gets its own — row 0, row 1, row 2.
That is the whole reason let exists. Use const by default, let when you
need to reassign, and var never.
Before let, the fix was to create a scope on purpose by wrapping the body in
an immediately invoked function. You will still meet this in older code, and it
is worth reading once because it shows the mechanism plainly:
for (var i = 0; i < 3; i++) {
(function (captured) {
handlers.push(() => console.log("row", captured));
})(i);
}Each call to that function makes a new scope holding its own captured. let
now does the same thing for you, once per iteration.
Hoisting and the temporal dead zone
Declarations are registered before any code runs. What differs is whether the name is usable before its line.
console.log(cache); // undefined — declared, not yet assigned
var cache = new Map();
console.log(total); // ReferenceError: Cannot access 'total' before initialization
let total = 0;var gives you undefined, which quietly produces wrong results further down.
let and const throw for the window between the top of the block and the
declaration line. That window is the temporal dead zone, and the error it
throws is a feature: it tells you where the bug is.
Function declarations are hoisted completely, so you can call renderLesson
above the line that defines it. Functions assigned to a const follow the
const rules and cannot be called early.
A closure is a function that remembers
Here is the entire idea: a function keeps access to the variables it was created next to, even after the outer function has returned.
function createCounter() {
let count = 0;
return function next() {
count += 1;
return count;
};
}
const nextId = createCounter();
console.log(nextId()); // 1
console.log(nextId()); // 2
const otherId = createCounter();
console.log(otherId()); // 1 — a separate countcreateCounter has returned by the time nextId() runs, yet count is still
there. The returned function holds a reference to the scope it was born in, so
that scope is not thrown away. Each call to createCounter makes a new scope,
which is why the two counters do not interfere.
Note that the closure captures the variable, not a copy of its value. That is
exactly why the var loop above misbehaves, and why this one works.
Private state without a class
Nothing outside the factory can reach items. There is no keyword doing that —
it is simply not on the scope chain from out there.
function createCart() {
const items = [];
return {
add(item) {
items.push(item);
return items.length;
},
total() {
return items.reduce((sum, item) => sum + item.price, 0);
},
};
}
const cart = createCart();
cart.add({ name: "Pro plan", price: 1900 });
console.log(cart.total()); // 1900
console.log(cart.items); // undefined — genuinely privateThree closures you already write
A function that remembers a setting. You configure once and call many times:
function createClient(baseUrl) {
return (path) => fetch(baseUrl + path);
}
const api = createClient("https://api.example.com");
api("/lessons");
api("/users/42");Memoisation. The cache lives in the closure, so it survives between calls but belongs to this wrapped function alone:
function memoise(fn) {
const cache = new Map();
return (input) => {
if (cache.has(input)) return cache.get(input);
const result = fn(input);
cache.set(input, result);
return result;
};
}
const slugify = memoise((title) => title.toLowerCase().split(" ").join("-"));Every event handler. The callback runs long after the surrounding function finished, and it still knows which lesson it belongs to:
function watchButton(button, lessonId) {
button.addEventListener("click", () => {
console.log("started lesson", lessonId);
});
}Try it yourself
Change var to let in the first loop and watch the output change.
Closures keep things alive
A closure is a reason for the garbage collector not to free memory. Usually that is what you want. Occasionally it is a leak.
function createLogger(response) {
const body = response.text; // several megabytes
return (message) => console.log(message, body.length);
}Only the length is ever used, but the whole string stays in memory for as long as the logger exists. Capture the small thing instead:
function createLogger(response) {
const size = response.text.length;
return (message) => console.log(message, size);
}The same applies to event listeners you never remove and intervals you never clear. The closure holds its scope, the scope holds the data.
What to remember
- Names resolve outward through the scope chain, and the first match wins.
letandconstscope to the block;varscopes to the function and causes the classic loop bug.- Reading a
letbefore its declaration throws, which is better than theundefinedthatvarhands you. - A closure captures variables, not values, and keeps them alive as long as the function is reachable.
Check yourself
4 questions · pass 3/4 to unlock The this Keyword
1.A loop with
for (var i = 0; i < 3; i++)pushes() => ithree times into an array. What does calling all three print?2.What happens when you read a
letvariable on a line above its declaration?3.Why does a counter built by a factory keep counting after the factory returned?
4.Which statement about closures and memory is true?
4 left to answer