Scope and Closures

Lesson 10 of 10

On this page

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.

Block scope — the modern default

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.

Function scope — the other common boundary

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

Lexical scoping — “where it’s written” decides what it can see

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!"

Closures — functions that remember where they came from

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.

The old way of doing things

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.