webdev.complete
🔧 Functions, Scope, Closures
JavaScript
Lesson 28 of 117
30 min

Scope & Closures

Lexical scope, closures, the dreaded `this`.

Scope is the rule that decides which variables are visible where. Closures are what happens when a function remembers the scope it was born in, even after that scope has finished running. If that sounds magical, that's because it kind of is, and it's the single most powerful idea in JavaScript.

Lexical scope: visibility follows the code

"Lexical" just means "based on where it is written." Inner blocks can see outer variables, but not the other way around:

js
const outer = "I am outside";

function speak() {
  const inner = "I am inside";
  console.log(outer);   // "I am outside"  ✅ inner can see outer
  console.log(inner);   // "I am inside"
}

speak();
console.log(inner);     // ReferenceError ❌ outer can't see inner

let/const are block scoped, var is not

js
if (true) {
  let blockOnly = "hi";
  var leaks = "oops";
}

console.log(leaks);     // "oops"          ← var ignores blocks
console.log(blockOnly); // ReferenceError   ← let respects them
This is one of the biggest reasons to use let and const everywhere. Predictable scope = fewer surprises.

Closures: functions that remember

When a function is created, it captures a snapshot of every variable it can see. Even after the outer function returns, the inner function keeps that snapshot alive. That captured environment is a closure.

js
function makeCounter() {
  let count = 0;

  return function () {
    count++;
    return count;
  };
}

const counter = makeCounter();
counter();   // 1
counter();   // 2
counter();   // 3

const fresh = makeCounter();
fresh();     // 1   - each call gets its own count

The returned function still has access to count after makeCounter() has returned. That's the closure. count is private to that counter instance.

Closures are how you get private state

Before classes had real private fields, closures were the only way to hide data in JS. They're still the cleanest way for small helpers:

js
function makeUser(name) {
  let _password = null;

  return {
    setPassword(p) { _password = p; },
    check(p)       { return p === _password; },
    // _password is invisible from outside
  };
}

const u = makeUser("Ada");
u.setPassword("secret");
u.check("wrong");   // false
u.check("secret");  // true
u._password;        // undefined - truly hidden

The famous var-in-loop gotcha

Pre-2015 JS code is littered with this bug. Run the following with var and every callback logs 3:

js
// Broken: var is function-scoped, so all callbacks share the SAME i
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// Logs: 3, 3, 3

// Fixed: let is block-scoped, each iteration gets its own i
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// Logs: 0, 1, 2

Each iteration with let creates a fresh binding, so the closure captures a different i every time. With var, all three callbacks point at the same variable, which is 3 by the time they run.

When closures cause memory leaks

Closures keep their captured variables alive. If you accidentally capture something large in a long-lived function, that memory can't be freed. The classic shape:

js
function setupHandler() {
  const hugeArray = new Array(1_000_000).fill("data");

  // This handler closes over hugeArray, even though it doesn't use it
  document.addEventListener("click", () => {
    console.log("clicked");
  });
}
Don't panic, just be aware
Modern engines are pretty good about discarding unused captures, but attaching long-lived listeners or timers inside scopes with lots of data is the most common JS leak pattern. Remove listeners when you're done, or define them outside the heavy scope.

Build your own counter

Here's a working counter factory. Extend it: add a decrement method, a reset, and a get()that doesn't change the value.

function makeCounter(start = 0, step = 1) {
  let count = start;

  return {
    increment() {
      count += step;
      return count;
    },
    // Add decrement, reset, and get below 👇
  };
}

const c = makeCounter();
console.log(c.increment());   // 1
console.log(c.increment());   // 2
console.log(c.increment());   // 3

// Each counter is independent
const c2 = makeCounter(100, 10);
console.log(c2.increment());  // 110
console.log(c.increment());   // 4 (c didn't share)

// The var-in-loop demo
console.log("\n--- var vs let in a loop ---");

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log("var i =", i), 0);
}

for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log("let j =", j), 0);
}

Quiz

Quiz1 / 3

What is a closure?

Recap

  • Lexical scope: a variable is visible everywhere inside the block it was declared in, and nowhere else.
  • let/const are block-scoped. var is function-scoped (avoid it).
  • A closure is a function plus the variables it captured. It keeps them alive after the outer scope returns.
  • Use closures for private state, factories, and memoization.
  • Closures keep memory in scope. Detach long-lived listeners and timers when you're done.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.