This final lesson ties the rest of the series together. Scope is simply
the answer to the question “where can I see and use this variable?” Every
variable you’ve declared in this series — with const, let, var, or as a
function parameter — lives somewhere, and understanding where helps you
predict how your code behaves and avoid an entire category of bugs.
As the Variables lesson mentioned, a variable declared with const or let
only exists inside the nearest pair of curly braces { } that contains it —
its block. Once you leave that block, the variable is gone:
if (true) {
const message = "only visible in here";
console.log(message); // "only visible in here"
}
console.log(message); // ReferenceError: message is not defined
This is the behavior you’ll rely on most: each if, for, while, or
plain { } block gets its own little world for the variables declared
inside it, which keeps unrelated parts of your code from stepping on each
other.
A function also creates its own scope: parameters and any const/let/var
declared inside it are only visible within that function.
function processOrder() {
const total = 42;
console.log(total); // 42
}
processOrder();
console.log(total); // ReferenceError: total is not defined
JavaScript uses lexical scoping: a piece of code can see the variables from every scope it’s physically nested inside, all the way out to the top of the file. This is why an inner function can freely use variables from the function that contains it:
function makeGreeter(greeting) {
function greet(name) {
return `${greeting}, ${name}!`; // sees `greeting` from the outer function
}
return greet;
}
const sayHello = makeGreeter("Hello");
console.log(sayHello("Ada")); // "Hello, Ada!"
That example above is actually a closure: greet keeps access to
greeting even after makeGreeter has finished running and returned. A
closure is simply a function bundled together with the variables from the
scope it was created in — and JavaScript creates them automatically,
everywhere, without any special syntax.
The classic, practical example is a counter that keeps its own private state:
function makeCounter() {
let count = 0;
return () => {
count += 1;
return count;
};
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
Each call to makeCounter creates a fresh, independent count variable
that only the returned function can see or change — there’s no way for
outside code to reach in and tamper with it directly. This pattern — using a
function to create private, protected state — comes up constantly, from
counters and caches to one-time setup logic.
var’s function-scoping (rather than block-scoping) used to make closures
surprisingly easy to get wrong. A famous example is a loop that tries to
create several functions, one per iteration:
var callbacks = [];
for (var i = 0; i < 3; i++) {
callbacks.push(function () {
console.log(i);
});
}
callbacks[0](); // 3 — not 0!
callbacks[1](); // 3
callbacks[2](); // 3
Because var i is function-scoped (or, here, global), all three callbacks
share the exact same i — and by the time any of them run, the loop has
already finished and i is 3. People weren’t expecting 0, 1, 2?
That’s the trap: every callback closes over the same variable, not a
snapshot of its value at the time.
The classic workaround was to wrap the loop body in an immediately invoked function expression (an IIFE, from the Functions lesson) just to force a new scope per iteration:
for (var i = 0; i < 3; i++) {
(function (capturedI) {
callbacks.push(function () {
console.log(capturedI);
});
})(i);
}
It works, but it’s a lot of ceremony just to get a fresh variable each time
through the loop. let fixes this at the source — it creates a new binding
for each iteration of the loop, so the natural, obvious code does exactly
what you’d expect:
const callbacks = [];
for (let i = 0; i < 3; i++) {
callbacks.push(() => console.log(i));
}
callbacks[0](); // 0
callbacks[1](); // 1
callbacks[2](); // 2
This single change — block scoping by default — quietly removed one of the
most notorious gotchas in JavaScript, and it’s a big part of why let and
const replaced var so thoroughly. It’s also a fitting place to end this
series: every lesson before this one — variables, data types, operators,
strings, arrays, objects, conditionals, loops, and functions — comes
together here, in how your code remembers, shares, and protects the values
it works with.
You’ve now covered the syntax you’ll meet most often in everyday JavaScript.
From here, the natural next steps are the topics this series intentionally
set aside — promises and async/await for handling things that take time,
and regular expressions for matching patterns in text — each of which
deserves a focused series of its own.