Closures, Scope & Execution Context
Every time you use a callback, a React hook, or a module â you're using closures. Senior engineers don't just know the definition; they reason about what a closure captures, when it captures it, and what happens when the captured value changes.
Execution Context: The Foundation
When JavaScript runs code, it creates an execution context â a container with three components:
Execution Context
âââ Variable Environment (let, const, function declarations)
âââ Lexical Environment (scope chain reference)
âââ this bindingThree types of execution contexts exist:
- Global â Created once when the program starts
- Function â Created each time a function is called
- Eval â Created inside
eval()(avoid)
The Call Stack
Execution contexts stack. Each function call pushes a new context; each return pops one:
function outer() {
const x = 10;
function inner() {
const y = 20;
console.log(x + y); // accesses outer's variable environment
}
inner();
}
outer();Call Stack:
[inner execution context] â top (currently executing)
[outer execution context]
[global execution context]Lexical Scope
JavaScript uses lexical scoping â the scope of a variable is determined by where the code is written, not where it's called.
const x = 'global';
function outer() {
const x = 'outer';
function inner() {
console.log(x); // 'outer' â lexically enclosed by outer()
}
return inner;
}
const fn = outer();
fn(); // 'outer' â NOT 'global', even though called in global scopeThe scope chain is fixed at definition time. inner will always see outer's x, regardless of where fn() is eventually called.
What a Closure Actually Is
A closure is formed when a function retains a reference to its lexical environment after the enclosing function has returned.
function createMultiplier(factor) {
return function(number) {
return number * factor; // `factor` is closed over
};
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
double(5); // 10 â factor is 2
triple(5); // 15 â factor is 3createMultiplier has returned, its execution context is off the stack, but factor survives because the returned function holds a reference to the lexical environment that contains it.
What Gets Captured
A closure captures the entire lexical environment, not just the variables it uses:
function createLeak() {
const hugeArray = new Array(1_000_000).fill('data');
const name = 'useful';
return function() {
return name; // Only uses `name`, but `hugeArray` is also retained
};
}Most engines optimize this (V8 only retains variables actually referenced), but it's important to understand the potential.
Scope Types
Block Scope (let, const)
if (true) {
let x = 10;
const y = 20;
}
// x and y are NOT accessible here
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Output: 0, 1, 2 â each iteration has its own `i`Function Scope (var)
if (true) {
var x = 10;
}
// x IS accessible here â var is function-scoped, not block-scoped
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Output: 3, 3, 3 â one shared `i`, already incremented to 3The Classic var Loop Closure
// Problem: all callbacks share the same `i`
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // 3, 3, 3
}
// Fix 1: IIFE creates a new scope per iteration
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(() => console.log(j), 100); // 0, 1, 2
})(i);
}
// Fix 2: Just use let (modern answer)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // 0, 1, 2
}Hoisting: The Full Picture
Hoisting isn't "moving declarations to the top." It's about how the creation phase of execution context works:
console.log(a); // undefined (var is hoisted, initialized to undefined)
console.log(b); // ReferenceError: Cannot access 'b' before initialization (TDZ)
console.log(c); // ReferenceError: c is not defined
var a = 1;
let b = 2;| Declaration | Hoisted? | Initial value | Accessible before declaration? |
|---|---|---|---|
var | Yes | undefined | Yes (value is undefined) |
let | Yes (technically) | Uninitialized (TDZ) | No (ReferenceError) |
const | Yes (technically) | Uninitialized (TDZ) | No (ReferenceError) |
function | Yes | Full function body | Yes (fully usable) |
class | Yes (technically) | Uninitialized (TDZ) | No (ReferenceError) |
Temporal Dead Zone (TDZ)
The TDZ is the gap between entering the scope and the let/const declaration:
{
// TDZ for `x` starts here
console.log(x); // ReferenceError
let x = 10; // TDZ ends here
}Closures in Real-World Patterns
Module Pattern (Pre-ES Modules)
const Counter = (() => {
let count = 0;
return {
increment: () => ++count,
decrement: () => --count,
getCount: () => count,
};
})();
Counter.increment(); // 1
Counter.count; // undefined â truly privateFactory Functions
function createLogger(prefix) {
let logCount = 0;
return {
log: (msg) => {
logCount++;
console.log(`[${prefix}] #${logCount}: ${msg}`);
},
getCount: () => logCount,
};
}
const apiLogger = createLogger('API');
const uiLogger = createLogger('UI');
apiLogger.log('Request sent'); // [API] #1: Request sent
uiLogger.log('Button clicked'); // [UI] #1: Button clickedReact Hooks (Closures in Action)
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(initial), [initial]);
return { count, increment, decrement, reset };
}Every React component render creates a new closure. Each render's event handlers "see" the state from that specific render â this is why stale closures are the #1 hooks bug.
The Stale Closure in React
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // Always 1! Closes over count=0 from first render
}, 1000);
return () => clearInterval(id);
}, []);
}Fixes:
- Functional updater:
setCount(c => c + 1)â doesn't readcount - Add
countto deps (but recreates interval each tick) - useRef for mutable latest value
Interview Signal
Senior candidates demonstrate:
- Execution context model â Variable environment, scope chain, how the call stack works
- Lexical vs dynamic scope â JavaScript is lexical;
thisis the only dynamic binding - Closure mechanics â What gets captured, when, and the memory implications
- Practical application â Module pattern, factory functions, hooks, stale closure debugging
- Hoisting nuance â TDZ for let/const, full hoisting for function declarations, not just "moved to top"