What Is It?
What Are Closures, Scope, and Hoisting?
These three concepts explain how JavaScript manages variables -- where they are accessible, how long they live, and some surprising behaviors that trip up even experienced developers.
- Scope determines where a variable can be accessed. If Aarav declares a variable inside a function, it cannot be used outside that function.
- Closures happen when an inner function remembers the variables from its outer function, even after the outer function has finished running. It is like a function carrying a backpack of variables.
- Hoisting is JavaScript's behavior of moving variable and function declarations to the top of their scope before execution. It explains why some code works in surprising ways.
// Closure example -- the inner function remembers 'count'
function createCounter() {
let count = 0; // This variable lives in the outer function
return function() { // This inner function is a closure
count++; // It can access 'count' even after createCounter returns
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
// The 'count' variable is private -- no one else can access it
Why Does It Matter?
Why Are These Concepts Important?
1. Understanding How JavaScript Actually Works
If you do not understand scope and closures, you will be confused by bugs that seem impossible. Variables appearing as undefined when you expected a value, loops behaving strangely, and callbacks not having access to data they should -- all of these are scope and closure issues.
2. The #1 JavaScript Interview Topic
"Explain closures" and "What is the output of this code?" are the most common JavaScript interview questions. The classic loop-with-closure trap question (var vs let inside a for loop) appears in almost every interview. Understanding these concepts deeply will make you stand out.
3. Foundation for Design Patterns
Closures enable powerful patterns: private variables (data hiding), factory functions (creating customized functions), memoization (caching expensive computations), and the module pattern (organizing code). React hooks (useState, useEffect) are built on closures.
4. Debugging Superpowers
When Priya sees a variable that is undefined when it should have a value, understanding scope tells her exactly where to look. When Rohan sees a stale value in a callback, understanding closures tells him why. These concepts turn mysterious bugs into obvious fixes.
Detailed Explanation
Detailed Explanation
1. Global Scope
Variables declared outside any function or block are in the global scope. They are accessible everywhere in your code:
const appName = "My App"; // Global scope
let userCount = 0; // Global scope
function greet() {
console.log(appName); // Can access global variables
userCount++;
}
greet();
console.log(userCount); // 1
// Global variables are accessible in all functions, if-blocks, loops, etc.
// But this is usually a BAD practice -- too many globals cause name conflicts2. Function Scope
Variables declared inside a function are only accessible inside that function. They do not exist outside:
function calculateTotal() {
const tax = 0.18; // Function scope -- only exists inside calculateTotal
const price = 100;
return price + price * tax;
}
console.log(calculateTotal()); // 118
// console.log(tax); // ReferenceError: tax is not defined
// console.log(price); // ReferenceError: price is not definedEach function call creates a NEW scope. Variables from one call do not interfere with another:
function add(a, b) {
const result = a + b; // Each call has its own 'result'
return result;
}
add(1, 2); // result = 3 (in this call)
add(3, 4); // result = 7 (in this call) -- completely independent3. Block Scope (let and const)
let and const are block-scoped. A block is anything between { and }:
if (true) {
let x = 10; // Block scope -- only exists inside this { }
const y = 20; // Block scope
var z = 30; // NOT block-scoped! var ignores blocks!
}
// console.log(x); // ReferenceError: x is not defined
// console.log(y); // ReferenceError: y is not defined
console.log(z); // 30 -- var leaks out of blocks!
// This is why you should always use let/const instead of var
for (let i = 0; i < 3; i++) {
// i only exists inside this loop
}
// console.log(i); // ReferenceError: i is not defined4. Lexical Scope
Lexical scope means an inner function can access variables from its outer function. The scope is determined by where the function is written in the code, not where it is called:
function outer() {
const message = "Hello from outer";
function inner() {
console.log(message); // inner can access outer's variables
}
inner();
}
outer(); // "Hello from outer"
// Nested three levels deep
function level1() {
const a = "level 1";
function level2() {
const b = "level 2";
function level3() {
console.log(a); // Can access level1's variable
console.log(b); // Can access level2's variable
}
level3();
}
level2();
}
level1();5. What Is a Closure?
A closure is created when an inner function retains access to its outer function's variables even after the outer function has returned. The inner function "closes over" the variables it needs:
function createGreeter(greeting) {
// 'greeting' is captured by the closure
return function(name) {
return greeting + ", " + name + "!";
};
}
const sayHello = createGreeter("Hello");
const sayNamaste = createGreeter("Namaste");
console.log(sayHello("Aarav")); // "Hello, Aarav!"
console.log(sayNamaste("Priya")); // "Namaste, Priya!"
// createGreeter has already returned, but the inner functions
// still remember their 'greeting' valuesEach call to createGreeter creates a separate closure with its own greeting variable. sayHello remembers "Hello" and sayNamaste remembers "Namaste".
6. Practical Closure: Counter Factory
function createCounter(start) {
let count = start || 0;
return {
increment: function() { count++; return count; },
decrement: function() { count--; return count; },
getCount: function() { return count; },
reset: function() { count = start || 0; return count; }
};
}
const counter1 = createCounter(0);
const counter2 = createCounter(100);
console.log(counter1.increment()); // 1
console.log(counter1.increment()); // 2
console.log(counter2.increment()); // 101
console.log(counter1.getCount()); // 2
console.log(counter2.getCount()); // 101
// Each counter has its own private 'count' variable7. Practical Closure: Private Variables
function createUser(name, age) {
// These are private -- no one can access them directly
let _name = name;
let _age = age;
return {
getName: function() { return _name; },
getAge: function() { return _age; },
setAge: function(newAge) {
if (newAge > 0 && newAge < 150) {
_age = newAge;
return true;
}
return false;
},
toString: function() {
return _name + " (age " + _age + ")";
}
};
}
const user = createUser("Aarav", 15);
console.log(user.getName()); // "Aarav"
console.log(user.getAge()); // 15
user.setAge(16);
console.log(user.toString()); // "Aarav (age 16)"
// user._name is undefined -- the variable is truly private8. Practical Closure: Memoization
function memoize(fn) {
const cache = {}; // Closure captures this cache
return function(n) {
if (cache[n] !== undefined) {
console.log(" Cache hit for " + n);
return cache[n];
}
console.log(" Computing " + n);
cache[n] = fn(n);
return cache[n];
};
}
function slowSquare(n) {
return n * n;
}
const fastSquare = memoize(slowSquare);
console.log(fastSquare(5)); // Computing 5 -> 25
console.log(fastSquare(5)); // Cache hit for 5 -> 25
console.log(fastSquare(3)); // Computing 3 -> 9
console.log(fastSquare(3)); // Cache hit for 3 -> 99. Hoisting: var Declarations
JavaScript hoists var declarations (but not their values) to the top of the function scope:
console.log(x); // undefined (not ReferenceError!)
var x = 10;
console.log(x); // 10
// JavaScript interprets the above as:
var x; // Declaration hoisted to top
console.log(x); // undefined (declared but not assigned)
x = 10; // Assignment stays in place
console.log(x); // 1010. Hoisting: let and const (Temporal Dead Zone)
let and const are also hoisted, but they are NOT initialized. Accessing them before declaration throws a ReferenceError. The period between hoisting and declaration is called the Temporal Dead Zone (TDZ):
// console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a = 5;
console.log(a); // 5
// console.log(b); // ReferenceError: Cannot access 'b' before initialization
const b = 10;
console.log(b); // 10
// var does NOT have a TDZ -- it returns undefined
console.log(c); // undefined
var c = 15;11. Hoisting: Function Declarations vs Expressions
Function declarations are fully hoisted (you can call them before they appear). Function expressions are NOT:
// Function declaration -- fully hoisted
sayHello(); // Works! "Hello!"
function sayHello() {
console.log("Hello!");
}
// Function expression -- NOT hoisted
// sayBye(); // TypeError: sayBye is not a function
var sayBye = function() {
console.log("Bye!");
};
sayBye(); // Works now
// Arrow function expression -- also NOT hoisted
// greet(); // ReferenceError: Cannot access 'greet' before initialization
const greet = () => console.log("Hi!");
greet(); // Works now12. The Classic Closure-in-Loop Trap
This is the most famous JavaScript interview question. With var, all iterations share the same variable:
// THE TRAP -- var in a loop
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // All print 3!
}, 100);
}
// Output: 3, 3, 3
// By the time setTimeout callbacks run, the loop is done and i is 3.
// All three closures share the SAME 'i' variable.
// THE FIX -- use let
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // Prints 0, 1, 2
}, 100);
}
// Output: 0, 1, 2
// let creates a new 'i' for each iteration. Each closure has its own copy.13. IIFE Pattern (Immediately Invoked Function Expression)
An IIFE creates a scope to avoid polluting the global scope:
(function() {
const secret = "hidden";
console.log("IIFE running");
// 'secret' is not accessible outside this function
})();
// console.log(secret); // ReferenceError
// IIFE with parameters
(function(name) {
console.log("Hello, " + name);
})("Aarav");
// Output: "Hello, Aarav"14. Module Pattern Using Closures
const Calculator = (function() {
// Private state
let history = [];
// Private function
function record(operation) {
history.push(operation);
}
// Public API (returned object)
return {
add: function(a, b) {
const result = a + b;
record(a + " + " + b + " = " + result);
return result;
},
subtract: function(a, b) {
const result = a - b;
record(a + " - " + b + " = " + result);
return result;
},
getHistory: function() {
return [...history]; // Return a copy
}
};
})();
console.log(Calculator.add(5, 3)); // 8
console.log(Calculator.subtract(10, 4)); // 6
console.log(Calculator.getHistory());
// ["5 + 3 = 8", "10 - 4 = 6"]
// Calculator.history is undefined -- it is private!
Code Examples
// --- Global Scope ---
const globalVar = "I am global";
function testScope() {
// --- Function Scope ---
const funcVar = "I am in a function";
console.log(globalVar); // Accessible
console.log(funcVar); // Accessible
if (true) {
// --- Block Scope ---
let blockLet = "let is block-scoped";
const blockConst = "const is block-scoped";
var blockVar = "var ignores blocks!";
console.log(blockLet); // Accessible inside block
}
// console.log(blockLet); // ReferenceError!
// console.log(blockConst); // ReferenceError!
console.log(blockVar); // "var ignores blocks!" -- var leaks out
}
testScope();
console.log(globalVar); // Accessible
// console.log(funcVar); // ReferenceError -- function scope
// console.log(blockVar); // ReferenceError -- function scope (var is function-scoped)
// Key lesson: let/const respect blocks. var only respects functions.let and const are block-scoped (trapped inside { }). var ignores block boundaries but respects function boundaries. This is the main reason to prefer let and const over var.// --- Basic closure ---
function makeMultiplier(multiplier) {
return function(number) {
return number * multiplier; // 'multiplier' is remembered
};
}
const double = makeMultiplier(2);
const triple = makeMultiplier(3);
const tenTimes = makeMultiplier(10);
console.log(double(5)); // 10
console.log(triple(5)); // 15
console.log(tenTimes(5)); // 50
// --- Counter with closure ---
function createCounter() {
let count = 0;
return {
increment: function() { return ++count; },
decrement: function() { return --count; },
getCount: function() { return count; }
};
}
const c1 = createCounter();
const c2 = createCounter(); // Separate closure!
console.log(c1.increment()); // 1
console.log(c1.increment()); // 2
console.log(c2.increment()); // 1 (independent!)
console.log(c1.getCount()); // 2
console.log(c2.getCount()); // 1makeMultiplier creates a new closure with its own multiplier value. double remembers 2, triple remembers 3, tenTimes remembers 10. Similarly, each createCounter call creates an independent counter with its own private count variable.// --- var hoisting ---
console.log("a:", a); // undefined (hoisted, not initialized)
var a = 10;
console.log("a:", a); // 10
// --- let/const: Temporal Dead Zone ---
try {
console.log(b);
} catch (e) {
console.log("b error:", e.message); // Cannot access 'b' before initialization
}
let b = 20;
console.log("b:", b); // 20
// --- Function declaration: fully hoisted ---
console.log("sayHi:", sayHi()); // "Hi!"
function sayHi() { return "Hi!"; }
// --- Function expression: NOT hoisted ---
try {
console.log(sayBye());
} catch (e) {
console.log("sayBye error:", e.message); // sayBye is not a function
}
var sayBye = function() { return "Bye!"; };
console.log("sayBye:", sayBye()); // "Bye!"
// --- Arrow function with const: TDZ ---
try {
greet();
} catch (e) {
console.log("greet error:", e.message); // Cannot access 'greet' before initialization
}
const greet = () => "Hello!";
console.log("greet:", greet()); // "Hello!"var declarations are hoisted and initialized to undefined. let and const are hoisted but NOT initialized (Temporal Dead Zone). Function declarations are fully hoisted (name + body). Function expressions and arrow functions follow the rules of their declaration keyword (var/let/const).// ========== THE TRAP: var in a loop ==========
console.log("--- var (broken) ---");
var funcsVar = [];
for (var i = 0; i < 3; i++) {
funcsVar.push(function() { return i; });
}
// All functions return 3 because they share the SAME 'i'
console.log(funcsVar[0]()); // 3
console.log(funcsVar[1]()); // 3
console.log(funcsVar[2]()); // 3
// ========== THE FIX: let in a loop ==========
console.log("--- let (correct) ---");
var funcsLet = [];
for (let j = 0; j < 3; j++) {
funcsLet.push(function() { return j; });
}
// Each function has its own 'j' because let is block-scoped
console.log(funcsLet[0]()); // 0
console.log(funcsLet[1]()); // 1
console.log(funcsLet[2]()); // 2
// ========== OLD FIX: IIFE ==========
console.log("--- IIFE fix ---");
var funcsIIFE = [];
for (var k = 0; k < 3; k++) {
(function(captured) {
funcsIIFE.push(function() { return captured; });
})(k); // Pass k as argument, creating a new scope
}
console.log(funcsIIFE[0]()); // 0
console.log(funcsIIFE[1]()); // 1
console.log(funcsIIFE[2]()); // 2var, all closures share the same variable i. By the time the functions run, the loop is done and i is 3. With let, each iteration creates a new block-scoped variable, so each closure captures its own value. The IIFE fix was the old solution before let existed -- it creates a new function scope to capture each value.// Module pattern: IIFE that returns a public API
const BankAccount = (function() {
// Private variables (not accessible from outside)
let balance = 0;
const transactions = [];
// Private function
function log(type, amount) {
transactions.push({
type: type,
amount: amount,
date: new Date().toISOString().split("T")[0],
balance: balance
});
}
// Public API
return {
deposit: function(amount) {
if (amount <= 0) return "Invalid amount";
balance += amount;
log("deposit", amount);
return "Deposited Rs " + amount + ". Balance: Rs " + balance;
},
withdraw: function(amount) {
if (amount <= 0) return "Invalid amount";
if (amount > balance) return "Insufficient balance";
balance -= amount;
log("withdraw", amount);
return "Withdrawn Rs " + amount + ". Balance: Rs " + balance;
},
getBalance: function() {
return balance;
},
getTransactions: function() {
return [...transactions]; // Return copy, not the original
}
};
})();
console.log(BankAccount.deposit(1000));
console.log(BankAccount.deposit(500));
console.log(BankAccount.withdraw(200));
console.log("Balance:", BankAccount.getBalance());
console.log("Transactions:", BankAccount.getTransactions().length);
// BankAccount.balance is undefined -- truly private!
console.log("Direct access:", BankAccount.balance);balance, transactions). The private variables cannot be accessed directly from outside -- only through the public methods. This is the JavaScript version of private/public access control.// --- Function Factory ---
function createValidator(min, max) {
return function(value) {
return value >= min && value <= max;
};
}
const isValidAge = createValidator(0, 120);
const isValidScore = createValidator(0, 100);
const isValidTemperature = createValidator(-50, 60);
console.log(isValidAge(25)); // true
console.log(isValidAge(150)); // false
console.log(isValidScore(85)); // true
console.log(isValidTemperature(-10)); // true
// --- Memoize ---
function memoize(fn) {
const cache = {};
return function() {
const key = JSON.stringify(arguments);
if (cache[key] !== undefined) return cache[key];
cache[key] = fn.apply(this, arguments);
return cache[key];
};
}
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const fastFib = memoize(function fib(n) {
if (n <= 1) return n;
return fastFib(n - 1) + fastFib(n - 2);
});
console.log(fastFib(10)); // 55
console.log(fastFib(20)); // 6765
console.log(fastFib(30)); // 832040 (instant with memoization)createValidator captures min and max, returning a function that validates any value against those bounds. The memoize function uses a closure to maintain a cache object that stores previously computed results, making repeated calls instant.Common Mistakes
Using var in a Loop with Closures
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // Prints 3, 3, 3
}, 100);
}for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // Prints 0, 1, 2
}, 100);
}var is function-scoped, so there is only one i shared by all iterations. let is block-scoped, creating a new i for each iteration. Always use let in loops.Expecting var to Be Block-Scoped
if (true) {
var secret = "hidden";
}
console.log(secret); // "hidden" -- oops, it leaked!if (true) {
let secret = "hidden";
}
// console.log(secret); // ReferenceError: secret is not definedvar is only scoped by functions, not by blocks like if, for, or while. let and const are properly block-scoped. Always use let or const to avoid accidental variable leaking.Using a Variable Before let/const Declaration
console.log(name); // ReferenceError!
let name = "Aarav";let name = "Aarav";
console.log(name); // "Aarav"let and const are hoisted but not initialized. The time between hoisting and the declaration line is the Temporal Dead Zone, where accessing the variable throws a ReferenceError. Always declare variables before using them.Assuming Closures Copy Values
function make() {
let x = 1;
const fn = function() { return x; };
x = 2; // Change x after creating the closure
return fn;
}
console.log(make()()); // 2 (not 1!)
// The closure references the variable, not the value at creation time// If you need the value at creation time, capture it explicitly
function make() {
let x = 1;
const captured = x; // Capture the current value
const fn = function() { return captured; };
x = 2;
return fn;
}
console.log(make()()); // 1Confusing Function Declaration and Expression Hoisting
sayHello(); // Works!
function sayHello() { console.log("Hello"); }
sayBye(); // TypeError: sayBye is not a function
var sayBye = function() { console.log("Bye"); };// Put function expressions BEFORE their first use
var sayBye = function() { console.log("Bye"); };
sayBye(); // Works
// Or use function declarations which are fully hoisted
function sayHello() { console.log("Hello"); }
sayHello(); // Works regardless of positionfunction name() { }) is fully hoisted -- you can call it before it appears in the code. A function expression (var name = function() { }) follows var hoisting rules: the variable exists but is undefined until the assignment line.Summary
- Scope determines where variables are accessible. JavaScript has three types: global scope (accessible everywhere), function scope (inside a function only), and block scope (inside { } for let/const only).
- var is function-scoped and ignores blocks. let and const are block-scoped. This is the main reason to always prefer let/const over var.
- Lexical scope means inner functions can access variables from their outer functions. The scope is determined by where the code is written, not where it is called.
- A closure is an inner function that retains access to its outer function's variables even after the outer function returns. The inner function 'closes over' the variables it needs.
- Practical closure patterns: counter factory (private state), function factory (customized functions), private variables (data hiding), and memoization (result caching).
- Hoisting: var declarations are moved to the top and initialized to undefined. let/const are hoisted but NOT initialized (Temporal Dead Zone). Function declarations are fully hoisted (can be called before they appear).
- The Temporal Dead Zone (TDZ) is the period between a let/const variable being hoisted and its actual declaration line. Accessing it during the TDZ throws a ReferenceError.
- Classic loop trap: var in a for loop creates ONE shared variable for all closures. let creates a NEW variable per iteration. Always use let in loops with callbacks or closures.
- IIFE (Immediately Invoked Function Expression) creates a scope to avoid polluting the global namespace. Syntax: (function() { ... })(). Used before let/const existed, still useful for the module pattern.
- The module pattern uses an IIFE that returns an object of public methods, with private variables trapped in the closure. This provides encapsulation -- private state that can only be accessed through the returned API.