Section 2 · Logic and Functions
Scope and Closures
18 min4 min readCode playground
Lesson at a glance
Predict where a variable can be used (block, function and global scope), avoid the old var pitfalls, and use closures to give functions private, remembered state such as an id counter.
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Every expense in Pocket needs a unique id so you can edit or delete exactly that one later. The obvious approach is a counter at the top of the file:
let nextId = 1;
function createId() {
return `exp-${nextId++}`;
}It works, until some other part of the program, perhaps a “reset” feature written months later, sets nextId = 1 again and you get two expenses called exp-1. The counter is visible everywhere, so anything can break it. To protect it you need to understand scope: the rules for where a name can be seen.
Blocks, functions and the global scope
Every pair of curly braces creates a block, and let and const names live in the block where they are declared. Functions create a scope too. Names declared outside every block and function are in the global scope, visible everywhere.
const appName = "Pocket"; // global
function describe(amount) {
const size = amount > 10000 ? "big" : "small"; // function scope
if (size === "big") {
const warning = "Think twice"; // block scope
console.log(appName, size, warning);
}
// console.log(warning) here would throw a ReferenceError
}
describe(12000);When the engine meets a name, it looks in the current scope first. If the name is not there, it looks in the enclosing scope, then the one around that, out to the global scope. If it still has not found it, you get ReferenceError: … is not defined. The lookup only goes outward: code inside a function can read the global appName, but code outside the function can never see size.
That is why the warning line is commented out. The block that declared warning has ended, so the name is gone.
If an inner scope declares a name that already exists outside, the inner one shadows the outer one: inside the block, the name means the inner variable. It is legal, but two different things with one name make code hard to read, so pick distinct names.
The trouble with var
Before 2015, JavaScript only had var, and var ignores blocks: it is visible throughout the whole function it is in. That produces one famous bug. Here the loop schedules three functions to run a moment later with setTimeout:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log("var:", i), 0);
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log("let:", j), 0);
}The var loop logs 3 three times, because there is only one i for the whole loop, and by the time the functions run it has reached 3. The let loop logs 0, 1 and 2, because let creates a fresh j for each pass and each function keeps its own. Those functions remembering variables is exactly what a closure is.
Closures: functions that remember
A function created inside another function can use the outer function’s variables. The surprise is that it keeps that access after the outer function has returned:
function createIdGenerator(prefix) {
let count = 0;
return () => {
count++;
return `${prefix}-${count}`;
};
}
const nextExpenseId = createIdGenerator("exp");
const nextCategoryId = createIdGenerator("cat");
console.log(nextExpenseId());
console.log(nextExpenseId());
console.log(nextCategoryId());
console.log(nextExpenseId());Follow the engine. Calling createIdGenerator("exp") creates a fresh prefix and count, builds the arrow function, and returns it. Normally a function’s variables vanish when it returns, but the arrow function still refers to count and prefix, so they stay alive, attached to it. That pairing of a function with the variables it was born next to is a closure.
Each call of createIdGenerator creates a new, separate set of variables. That is why nextCategoryId() starts from 1 and the expense ids carry on from where they were.
Now look at the safety you gained. There is no global count. No other code can read it, reset it or set it to a duplicate value. The only way to affect it is to call the function you were given, which can only ever move it forward. This is the standard way to give a function private state in JavaScript.
A closure can protect more than a counter. Here is a budget that can only go down through the function you hand out:
function createBudget(limitCents) {
let remaining = limitCents;
return (spentCents) => {
remaining -= spentCents;
return remaining;
};
}
const spend = createBudget(2000);
console.log(spend(450));
console.log(spend(1220));Section 2 is complete: you can make decisions, repeat work, and package it in functions with their own private state. Section 3 turns to data, starting with the list you have been looping over: the array.
Key takeaways
- Scope is where a name can be used:
letandconstlive in the nearest block{ }, and lookups go outward, never inward. - An inner name with the same name as an outer one shadows it inside the inner scope, which is legal but easy to misread.
varignores blocks and is scoped to the whole function, which causes classic bugs; useletandconst.- A closure is a function that keeps access to the variables of the scope where it was created, even after that outer function has returned.
- Each call of an outer function creates a fresh set of variables, so every closure it returns has its own private state.