DNA⚡ JavaScriptClosures, Scope & Execution Context
ðŸĶ–DinosaurJavaScriptFundamentalsInternals

Closures, Scope & Execution Context

Closures aren't a feature — they're a consequence of how JavaScript scoping works. Understanding the execution context model unlocks debugging, hooks, and module patterns.

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 binding

Three types of execution contexts exist:

  1. Global — Created once when the program starts
  2. Function — Created each time a function is called
  3. 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 scope

The 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 3

createMultiplier 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 3

The 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;
DeclarationHoisted?Initial valueAccessible before declaration?
varYesundefinedYes (value is undefined)
letYes (technically)Uninitialized (TDZ)No (ReferenceError)
constYes (technically)Uninitialized (TDZ)No (ReferenceError)
functionYesFull function bodyYes (fully usable)
classYes (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 private

Factory 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 clicked

React 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 read count
  • Add count to deps (but recreates interval each tick)
  • useRef for mutable latest value

Interview Signal

Senior candidates demonstrate:

  1. Execution context model — Variable environment, scope chain, how the call stack works
  2. Lexical vs dynamic scope — JavaScript is lexical; this is the only dynamic binding
  3. Closure mechanics — What gets captured, when, and the memory implications
  4. Practical application — Module pattern, factory functions, hooks, stale closure debugging
  5. Hoisting nuance — TDZ for let/const, full hoisting for function declarations, not just "moved to top"