Closures

Updated 6 interview angles 5 min read source
On this page7
  1. The definition, and the thing that makes it interesting
  2. The loop question
  3. What closures are actually for
  4. Where they leak
  5. The React version of the same bug
  6. Related
  7. Interview angle

Closures

A closure is a function together with the scope it was created in. That is the whole definition — the interesting part is that the scope is captured by reference, and almost every closure question is really about that.

The definition, and the thing that makes it interesting

js
function counter() {
  // not garbage collected: inc still references it
  let n = 0;
  return function inc() {
    return ++n;
  };
}

const a = counter();
const b = counter();
a(); a();   // 2
b();        // 1 — separate scope, separate n

Each call to counter creates a new scope, so a and b close over different n. That is what makes closures usable as instances.

Variables are captured, not copied. The closure sees the variable’s current value, whenever it runs:

js
let x = 1;
const show = () => console.log(x);
x = 2;
show();     // 2, not 1

The loop question

The one that gets asked, and the reason let exists:

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

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

var is function-scoped: there is one i, all three callbacks close over it, and by the time they run the loop has finished and i is 3.

let is block-scoped and the spec gives a for loop a fresh binding per iteration, copying the value forward. Three separate variables, three separate closures.

Before let, the fix was an IIFE to manufacture a scope:

js
for (var i = 0; i < 3; i++) {
  (function (j) {
    setTimeout(() => console.log(j));   // 0 1 2
  })(i);
}

Knowing that is why let behaves as it does is worth more than knowing the output.

What closures are actually for

Private state, which is the pattern behind modules and factories:

js
function createStore(initial) {
  // unreachable from outside
  let state = initial;
  const listeners = new Set();

  return {
    get: () => state,
    set(next) {
      state = next;
      listeners.forEach((f) => f(state));
    },
    subscribe(f) {
      listeners.add(f);
      // closes over f
      return () => listeners.delete(f);
    },
  };
}

Nothing outside can reach state except through set. subscribe returning an unsubscribe function that closes over f is the same idea — that returned function is the only thing that still knows which listener to remove.

Partial application, where the closure holds the fixed arguments:

js
const log = (level) => (msg) => console.log(`[${level}] ${msg}`);
const warn = log("warn");
warn("disk almost full");     // [warn] disk almost full

Currying is the strict version — one argument per call — and partial application is fixing some of them. Both are closures holding arguments.

Where they leak

A closure keeps its entire enclosing scope alive, not just the variables it uses. That is what turns a closure into a memory leak:

js
function attach(el) {
  const huge = new Array(1e6).fill("data");   // 8 MB
  el.addEventListener("click", () => {
    // never touches `huge`
    console.log("clicked");
  });
}

The handler references nothing from huge, but engines commonly retain the whole scope, so huge lives as long as the listener does — and the listener lives as long as the element. Two fixes, and the second is the real one:

js
function attach(el) {
  const controller = new AbortController();
  el.addEventListener("click", onClick, { signal: controller.signal });
  // removes it, frees the scope
  return () => controller.abort();
}

Return a cleanup function and call it. That is exactly what a React useEffect cleanup or a Vue onUnmounted is for — see useEffect Deep — Cleanup, StrictMode Double-Invoke, Dep-Array Bugs, Effect Events.

Gotcha: the leak is not the closure, it is the lifetime. A closure held by a long-lived thing — a global listener, an interval, a subscription — pins everything it closed over. A closure that goes out of scope costs nothing.

The React version of the same bug

Stale closures are the dominant hook bug, and they are this rule applied to renders:

jsx
useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000);
  return () => clearInterval(id);
}, []);        // captures count === 0, forever

The effect ran once and closed over the count from that render. The fix is the updater form, setCount((c) => c + 1), which needs no captured value. Vue does not have this problem because a ref is a live box rather than a value — Vue versus React.

Interview angle 6

  • “What is a closure?” - a function together with the scope it was defined in, kept alive because the function still references it. Each call to the outer function creates a new scope, which is what lets closures act as independent instances.
  • “Why does a var loop with setTimeout print 3 3 3?” - var is function-scoped, so there is one i shared by all three callbacks, and by the time they run the loop has finished. let gets a fresh binding per iteration, so each callback closes over its own copy.
  • “Are variables captured by value or reference?” - by reference. The closure reads the variable when it runs, not when it was created, which is why reassigning it afterwards changes what the closure sees.
  • “How do closures cause memory leaks?” - by lifetime, not by existing. A closure held by something long-lived — a listener, an interval, a subscription — pins its whole enclosing scope, including large values it never touches. Return a cleanup function and call it.
  • “How do you make private state in JavaScript?” - a closure over a variable, exposed only through returned methods. That is the module pattern, and it predates #private class fields, which are the modern alternative for classes.
  • “What is a stale closure in React?” - an effect or callback that captured a value from an earlier render and never saw the update. The [] dependency array is the usual cause; the updater form of setState avoids needing the captured value at all.