Scope and Closures

โ–ถ Try It Yourself

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)
Note: A closure is not a special syntax โ€” it is a natural consequence of how JavaScript resolves variable names. Any function defined inside another function automatically has access to the outer function’s variables โ€” even after the outer function returns. The inner function “closes over” the variables it references, keeping them alive in memory as long as the inner function exists.
Tip: Closures are the mechanism behind the module pattern โ€” a technique for creating private state in JavaScript without classes. Wrap related functions and state in an immediately invoked function expression (IIFE) or a factory function, expose only what the outside world needs, and keep implementation details hidden. This is the same principle that ES6 modules formalise.
Warning: The classic loop closure bug: 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

▶ Try It Yourself

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

🧠 Test Yourself

What will for (var i=0; i<3; i++) { setTimeout(()=>console.log(i), 0) } output?





โ–ถ Try It Yourself