Scope determines where variables are visible and accessible in your code. Closures โ one of JavaScript’s most powerful and distinctive features โ occur when a function retains access to its outer scope’s variables even after that outer function has finished executing. Understanding scope and closures is the key to understanding how JavaScript manages data, how module patterns work, how event handlers remember their context, and why certain loop bugs occur. This lesson builds the mental model you need for advanced JavaScript.
Scope Types
| Scope | Created By | Accessible From |
|---|---|---|
| Global scope | Outside all functions and blocks | Anywhere in the program |
| Function scope | function() { } |
Inside the function only |
| Block scope | { } โ if, for, while, explicit |
Inside the block only (let/const) |
| Module scope | type="module" script |
Inside the module file only |
| Lexical scope | Where the function is defined (not called) | The rule โ inner functions access outer vars |
Scope Chain
| Level | What JavaScript Searches | If Not Found |
|---|---|---|
| 1st | Current function/block scope | Move up to outer scope |
| 2nd | Enclosing function scope | Move up again |
| 3rd | Outer enclosing scopes (repeats) | Keep moving up |
| Last | Global scope | ReferenceError โ variable not found |
Closure Use Cases
| Pattern | Description | Example |
|---|---|---|
| Data privacy | Variables hidden from outside world | Private counter, encapsulated state |
| Factory functions | Functions that create configured functions | createMultiplier(3) returns x => x * 3 |
| Memoisation | Cache function results using closed-over Map | Avoid recomputing expensive results |
| Event handler state | Handler remembers its creation-time data | Button knows its own ID without DOM lookup |
| Partial application | Pre-fill some arguments, return function for rest | const add5 = add.bind(null, 5) |
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0) } logs 3, 3, 3 โ not 0, 1, 2. All three arrow functions close over the same var i variable, which is 3 by the time they run. Fix it by using let i instead โ each loop iteration creates a new block-scoped binding that the closure captures independently.Basic Example
// โโ Scope chain lookup โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const globalVar = 'global';
function outer() {
const outerVar = 'outer';
function inner() {
const innerVar = 'inner';
// inner can see all three: its own, outer's, and global
console.log(innerVar, outerVar, globalVar); // inner outer global
}
inner();
// console.log(innerVar); // ReferenceError โ innerVar only exists in inner()
}
outer();
// โโ Closure โ function remembers its birth environment โโโโโโโโโโโโโโโโโโโ
function createCounter(start = 0, step = 1) {
let count = start; // this variable lives in the closure
return {
increment() { count += step; return count; },
decrement() { count -= step; return count; },
reset() { count = start; return count; },
value() { return count; },
};
}
const counter = createCounter(0, 1);
const counter5 = createCounter(100, 5);
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.decrement()); // 1
console.log(counter.value()); // 1
console.log(counter5.increment()); // 105
console.log(counter5.increment()); // 110
// Each counter has its own independent 'count' โ separate closure
// โโ Factory function โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function createMultiplier(factor) {
return n => n * factor; // closes over 'factor'
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
const times10 = createMultiplier(10);
console.log(double(7)); // 14
console.log(triple(7)); // 21
console.log(times10(7)); // 70
// โโ Memoisation with closure โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function memoize(fn) {
const cache = new Map(); // closed over โ persists between calls
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log(`[cache hit] ${key}`);
return cache.get(key);
}
const result = fn(...args);
cache.set(key, result);
return result;
};
}
function slowFibonacci(n) {
if (n <= 1) return n;
return slowFibonacci(n - 1) + slowFibonacci(n - 2);
}
const fastFib = memoize(slowFibonacci);
console.log(fastFib(10)); // 55 (computed)
console.log(fastFib(10)); // 55 (cache hit โ instant)
// โโ The loop closure bug and the fix โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Bug: var is function-scoped โ all closures share the same i
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log('var:', i), 0); // logs 3, 3, 3
}
// Fix 1: use let โ each iteration creates a new binding
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log('let:', j), 0); // logs 0, 1, 2
}
// Fix 2: IIFE captures the value
for (var k = 0; k < 3; k++) {
((captured) => {
setTimeout(() => console.log('iife:', captured), 0); // 0, 1, 2
})(k);
}
How It Works
Step 1 โ Scope Chain Searches Outward
When JavaScript resolves a variable name, it looks in the current scope first. If not found, it moves to the enclosing scope, then the next, all the way to global scope. It never looks inward โ inner scopes are private to their functions. This one-way visibility chain is called the scope chain.
Step 2 โ Closures Keep Variables Alive
Normally, a function’s local variables are garbage collected when the function returns. But if an inner function references those variables, the JavaScript engine keeps them alive โ the inner function’s closure holds a reference. In createCounter, count lives as long as any of the returned methods exist.
Step 3 โ Each Factory Call Creates an Independent Closure
createCounter(0, 1) and createCounter(100, 5) each create a fresh execution context with their own independent count and step variables. The two counters cannot interfere with each other โ they close over different variable instances.
Step 4 โ Memoisation Uses a Closed-Over Cache
The cache Map inside memoize persists across all calls to the returned function because the returned function closes over it. Each unique argument set is stored once. The cache is completely private โ no external code can access or corrupt it.
Step 5 โ let Fixes the Loop Bug with Block Scope
With var, there is one i shared by all iterations. With let, each iteration of the loop creates a new block-scoped binding for j. Each arrow function in setTimeout closes over its own independent j โ a different variable for each iteration.
Real-World Example: Private State Module
// auth-store.js โ private state via closure (module pattern)
const authStore = (() => {
// Private state โ inaccessible from outside
let currentUser = null;
let sessionToken = null;
let loginTime = null;
const listeners = new Set();
// Private helper
function notifyListeners(event, data) {
listeners.forEach(fn => fn(event, data));
}
// Public API โ only these are exposed
return {
login(user, token) {
currentUser = user;
sessionToken = token;
loginTime = new Date();
notifyListeners('login', user);
},
logout() {
const user = currentUser;
currentUser = null;
sessionToken = null;
loginTime = null;
notifyListeners('logout', user);
},
getUser() { return currentUser ? { ...currentUser } : null; },
isLoggedIn() { return currentUser !== null; },
getToken() { return sessionToken; },
getSessionDuration() {
if (!loginTime) return 0;
return Math.floor((Date.now() - loginTime) / 1000);
},
onChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn); // returns unsubscribe function
},
};
})();
// Usage
const unsub = authStore.onChange((event, user) => {
console.log(`Auth event: ${event}`, user?.name ?? 'nobody');
});
authStore.login({ id: 1, name: 'Alice', role: 'admin' }, 'tok_abc123');
console.log(authStore.isLoggedIn()); // true
console.log(authStore.getUser()); // { id: 1, name: 'Alice', role: 'admin' }
authStore.logout();
console.log(authStore.isLoggedIn()); // false
unsub(); // stop listening
Common Mistakes
Mistake 1 โ The var loop closure bug
โ Wrong โ all callbacks share the same var variable:
for (var i = 0; i < 5; i++) {
buttons[i].addEventListener('click', () => console.log(i));
// All buttons log 5 โ they all close over the same i
}
โ Correct โ let creates a new binding per iteration:
for (let i = 0; i < 5; i++) {
buttons[i].addEventListener('click', () => console.log(i));
// Each button logs its own i: 0, 1, 2, 3, 4
}
Mistake 2 โ Accidentally creating global variables
โ Wrong โ missing var/let/const creates a global variable:
function calculate() {
result = 42; // no declaration โ result is now global!
}
โ Correct โ always declare variables:
function calculate() {
const result = 42; // function-scoped โ safe
}
Mistake 3 โ Expecting closures to capture values, not references
โ Wrong โ closure captures the variable reference, not its current value:
let message = 'hello';
const fn = () => console.log(message);
message = 'goodbye';
fn(); // logs 'goodbye' โ not 'hello'!
โ Correct โ capture the value by passing it as an argument or copying it:
const capturedFn = ((msg) => () => console.log(msg))(message);
capturedFn(); // logs 'hello' โ value was captured at creation time
Quick Reference
| Concept | Key Rule | Example |
|---|---|---|
| Scope chain | Inner can see outer; outer cannot see inner | Nested functions access parent vars |
| Closure | Function retains access to outer vars after outer returns | Counter, cache, private state |
| Block scope | let/const are scoped to nearest { } | Loop variable doesn’t leak out |
| Factory function | Returns function that closes over configuration | createMultiplier(3) |
| Loop bug fix | Use let instead of var in for loops | Each iteration gets fresh binding |
| Module pattern | IIFE + closure = private state + public API | authStore pattern above |