Lesson 158 min read

Closures & Scope

Understand where variables live, who can see them, and the magic of closures

Scope — Where Variables Live

Every variable in JavaScript has a scope — the region of your code where that variable is accessible. Think of scope like rooms in a house. If you're in the kitchen, you can see everything in the kitchen. You can also see into the living room through the doorway (outer scope). But you can't see into the closed bedroom (another function's scope).

JavaScript has three types of scope:

  • Global scope — variables declared outside any function or block. Visible everywhere. Like the hallway — everyone can see it.
  • Function scope — variables declared inside a function with var, let, or const. Only visible inside that function.
  • Block scope — variables declared with let or const inside curly braces {}. Only visible inside that block. var does NOT have block scope — this is one reason to avoid it.
JavaScript's scope chain lookup: when you reference a variable, the engine searches from the innermost scope outward until it finds a match or throws a ReferenceError.

Scope in Action

// Global scope
let globalVar = "I'm global!";
function showScope() {
// Function scope
let functionVar = "I'm in a function!";
console.log(globalVar); // ✅ can see global
console.log(functionVar); // ✅ can see own variable
if (true) {
// Block scope
let blockVar = "I'm in a block!";
const alsoBlock = "Me too!";
var notBlock = "I ignore blocks! (var is function-scoped)";
console.log(blockVar); // ✅
console.log(functionVar); // ✅ can see outer function scope
}
// console.log(blockVar); // ❌ ReferenceError — block scope!
console.log(notBlock); // ✅ var escapes the block!
}
showScope();
// console.log(functionVar); // ❌ ReferenceError — function scope!
Output
I'm global!
I'm in a function!
I'm in a block!
I'm in a function!
I ignore blocks! (var is function-scoped)

Hoisting — Variables That Time-Travel

JavaScript does something weird before running your code: it hoists (lifts) declarations to the top of their scope (related to how the call stack manages execution contexts). But here's the catch — only the declaration is hoisted, not the value.

  • var declarations are hoisted and initialized to undefined. You can use them before the line where they're declared (but you'll get undefined).
  • let and const declarations are hoisted too, but they're placed in a "temporal dead zone" (TDZ) — accessing them before declaration throws a ReferenceError.
  • Function declarations are fully hoisted — you can call them before they appear in your code!
  • Function expressions & arrow functions follow the rules of their variable (var/let/const).

Hoisting Surprises

// Function declarations are fully hoisted
console.log(greet("World")); // "Hello, World!" — works!
function greet(name) {
return `Hello, ${name}!`;
}
// var is hoisted but initialized to undefined
console.log(x); // undefined (not an error!)
var x = 10;
console.log(x); // 10
// let/const are hoisted but in the "temporal dead zone"
// console.log(y); // ❌ ReferenceError: Cannot access 'y' before initialization
let y = 20;
console.log(y); // 20
// Arrow functions follow their variable rules
// console.log(double(5)); // ❌ ReferenceError (const is in TDZ)
const double = (n) => n * 2;
console.log(double(5)); // 10
Output
Hello, World!
undefined
10
20
10

Closures — The Superpower

A closure is when a function "remembers" the variables from the scope where it was created, even after that scope has finished executing. It's like taking a photo at a party — the party ends, but the photo captures everyone who was there.

Closures happen naturally in JavaScript. Whenever a function is defined inside another function, the inner function has access to the outer function's variables — forever. This is how JavaScript implements data privacy, callbacks, and many common patterns.

Closures in Action

// Basic closure — inner function remembers outer variables
function createCounter() {
let count = 0; // this variable is "enclosed"
return {
increment() { count++; return count; },
decrement() { count--; return count; },
getCount() { return count; }
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.increment()); // 3
console.log(counter.decrement()); // 2
// count is private! We can't access it directly:
// console.log(count); // ❌ ReferenceError
// Each call creates a NEW closure with its own variables
const counter2 = createCounter();
console.log(counter2.getCount()); // 0 (independent!)
console.log(counter.getCount()); // 2 (still has its own count)
// Practical use: function factory
function createGreeter(greeting) {
return (name) => `${greeting}, ${name}!`;
}
const sayHello = createGreeter("Hello");
const sayHola = createGreeter("Hola");
console.log(sayHello("Kai")); // "Hello, Kai!"
console.log(sayHola("Kai")); // "Hola, Kai!"
Output
1
2
3
2
0
2
Hello, Kai!
Hola, Kai!

Closures in Loops & IIFE

One of the most famous closure gotchas involves loops. With var (which is function-scoped, not block-scoped), all iterations of a loop share the same variable. By the time a callback runs, the loop is done and the variable holds its final value. Using let (block-scoped) fixes this because each iteration gets its own copy.

An IIFE (Immediately Invoked Function Expression) is a function that runs the moment it's defined. Before let and const existed, IIFEs were used to create private scope. You'll still see them in older code and in some build patterns.

The Loop Gotcha & IIFE

// The classic closure-in-loop bug with var
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log("var:", i), 100);
}
// Prints: 3, 3, 3 — because var is shared, and i is 3 when callbacks run
// Fixed with let — each iteration gets its own i
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log("let:", j), 200);
}
// Prints: 0, 1, 2 — each callback closes over its own j
// IIFE — Immediately Invoked Function Expression
// Creates a private scope
const gameModule = (() => {
let score = 0; // private!
let highScore = 0; // private!
return {
addPoints(pts) {
score += pts;
if (score > highScore) highScore = score;
return score;
},
reset() {
score = 0;
},
getHighScore() {
return highScore;
}
};
})(); // <-- () immediately invokes it
console.log(gameModule.addPoints(100)); // 100
console.log(gameModule.addPoints(50)); // 150
gameModule.reset();
console.log(gameModule.getHighScore()); // 150
// console.log(score); // ❌ ReferenceError — score is private!
Output
var: 3
var: 3
var: 3
let: 0
let: 1
let: 2
100
150
150
Note: Closures are how JavaScript achieves truly private variables. Unlike other languages with 'private' keywords, JavaScript (before class #private fields) used closures to hide data. The pattern is: create variables in a function, return functions that access them. The outer code can use the returned functions but can never touch the variables directly. It's like a vending machine — you interact through buttons, but you can't reach in and grab the snacks.

Quick check

What type of scope does 'let' have that 'var' does NOT?
Async Programming