JavaScriptIntermediate

Scope, Hoisting & Closures: How JavaScript Sees Your Variables

Why can a function remember variables after its parent has returned? Master lexical scope, hoisting, the TDZ, and closures — the concept behind every callback, module, and React hook.

4 sections · ~25 min · 5-question quiz (pass ≥ 70%)

1Lexical Scope: Where Variables Live

JavaScript uses lexical (static) scope: where you write a variable determines where it's visible, not where or when the code runs.

const outer = "I'm outside";

function parent() {
  const middle = "I'm in parent";
  function child() {
    const inner = "I'm in child";
    console.log(outer, middle, inner); // all visible here
  }
  child();
  // console.log(inner); // ReferenceError — inner scopes aren't visible outward
}

Scopes nest like one-way mirrors: inner code sees outward; outer code cannot see in.

let and const are block-scoped (visible only inside the nearest { }), while var is function-scoped — it leaks out of if blocks and loops, which is a major reason it's avoided.

2Hoisting and the Temporal Dead Zone

Before executing your code, the engine registers declarations. This is hoisting — but different declarations hoist differently:

sayHi();               // "Hi!" — works: function declarations hoist fully
function sayHi() { console.log("Hi!"); }

console.log(x);        // undefined — var hoists, initialized to undefined
var x = 5;

console.log(y);        // ReferenceError! — let hoists but is uninitialized
let y = 5;

The zone between entering a scope and the let/const declaration line is the Temporal Dead Zone (TDZ) — the variable exists but touching it throws.

This is a feature, not a bug: the TDZ turns "silently got undefined" bugs into loud, immediate errors.

3Closures: Functions That Remember

A closure is a function bundled with the scope it was created in. The function keeps that scope alive — even after the outer function has returned.

function makeCounter() {
  let count = 0;                 // lives in makeCounter's scope
  return function () {
    count++;                     // still accessible!
    return count;
  };
}

const counter = makeCounter();   // makeCounter has returned...
counter(); // 1
counter(); // 2  — count survives between calls
const other = makeCounter();
other();   // 1  — each call to makeCounter creates a fresh scope

Key insights:

  • The inner function closes over the variable itself, not a snapshot of its value.
  • Each invocation of the outer function creates an independent closure.
  • This is JavaScript's original mechanism for private state — no class needed.

4Closures in the Wild (and One Classic Trap)

Closures power patterns you use every day:

// Module pattern: public API over private state
function createWallet() {
  let balance = 0; // private — nothing outside can touch it
  return {
    deposit(amount) { balance += amount; },
    getBalance()    { return balance; },
  };
}

// Event handlers & callbacks remember their context
function setupButton(label) {
  button.addEventListener("click", () => console.log(`${label} clicked`));
}

The classic loop trap:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i));  // 3, 3, 3 — all share ONE i
}

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i));  // 0, 1, 2 — let gives each iteration its own i
}

With var there is a single function-scoped i that ends at 3 before any timeout runs. let creates a fresh binding per iteration — each closure captures its own.

Ready to test yourself?

Sign in to take the quiz, track progress, and earn a certificate.

Sign in