Overview

A closure is a function that remembers the variables from the scope in which it was created, even after that scope has finished executing. Closures are the mechanism behind callbacks, event handlers, module patterns, and React hooks. This tutorial explains how they work and how to use them without introducing memory leaks or stale data.

Lexical Scope in One Minute

function outer() {
  const message = "hello";

  function inner() {
    console.log(message);
  }

  inner();
}

outer();   // hello

inner can read message because it was defined inside the scope where message exists. This is lexical scoping: the position of the function in the source code determines what it can access.

Closures Happen When a Function Outlives Its Scope

function makeCounter() {
  let count = 0;

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

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

makeCounter has finished running, yet the returned function still has access to count. That preserved binding is the closure.

Each Closure Captures Its Own Variable

function createGreeters() {
  const names = ["Alice", "Bob", "Carol"];
  return names.map(name => () => `Hello, ${name}`);
}

const greeters = createGreeters();
greeters[0]();   // Hello, Alice
greeters[1]();   // Hello, Bob

Each arrow function captures its own name. This works because arrow functions create a new scope per iteration in map.

The Classic Loop Bug

// Broken with var
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// 3, 3, 3

// Correct with let
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// 0, 1, 2

var is function-scoped, so all three callbacks share one i. let is block-scoped, so each iteration gets a fresh binding.

Emulating Private Variables

function createBankAccount(initialBalance) {
  let balance = initialBalance;

  return {
    deposit(amount) {
      if (amount <= 0) throw new Error("Invalid amount");
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      if (amount > balance) throw new Error("Insufficient funds");
      balance -= amount;
      return balance;
    },
    getBalance() {
      return balance;
    },
  };
}

const account = createBankAccount(100);
account.deposit(50);      // 150
account.getBalance();     // 150
account.balance;          // undefined — not accessible

Before ES2022 class private fields (#balance), closures were the standard way to hide state in JavaScript.

The Module Pattern

const Counter = (() => {
  let count = 0;

  return {
    increment: () => ++count,
    reset: () => { count = 0; },
    get value() { return count; },
  };
})();

Counter.increment();
Counter.increment();
console.log(Counter.value);   // 2

Stale Closures in React

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      // BUG: always logs 0 because the closure captured the first render's count
      console.log(count);
    }, 1000);
    return () => clearInterval(id);
  }, []);  // empty dependency array

  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

Two fixes:

// Option 1: use a functional update (no dependency on count)
setCount(c => c + 1);

// Option 2: include count in the dependency array
useEffect(() => { ... }, [count]);

Memory Considerations

Closures keep their captured variables alive. If a closure is referenced from a long-lived object, everything it captures stays in memory.

PatternRiskMitigation
Event listener that captures a large DOM subtreeDetached DOM nodes stay in memoryRemove listeners with removeEventListener
Interval that captures stateRuns forever, blocks GCAlways clearInterval on cleanup
Cache object inside a closureUnbounded growthBound the cache size or use a WeakMap

Closures vs Classes

AspectClosureClass
SyntaxFunction returning functionsclass with methods
Private stateVariables in outer scope#field syntax
InheritanceManual compositionNative extends
DebuggingHarder to inspectNamed methods in stack traces
MemorySmaller per instanceMethods shared on the prototype

Both are valid. Use closures for small factories and callbacks; use classes when you need inheritance, or when a large number of instances should share method implementations.

Quick Reference

  • A closure is any function that accesses variables from an outer scope, even after that scope has exited.
  • Every function in JavaScript forms a closure with its lexical environment.
  • let and const create per-iteration bindings in loops; var does not.
  • Closures are the foundation of the module pattern, callbacks, and React hooks.
  • Always clean up timers and listeners to avoid retaining captured state.