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
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 nEach 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:
let x = 1;
const show = () => console.log(x);
x = 2;
show(); // 2, not 1The loop question
The one that gets asked, and the reason let exists:
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:
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:
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:
const log = (level) => (msg) => console.log(`[${level}] ${msg}`);
const warn = log("warn");
warn("disk almost full"); // [warn] disk almost fullCurrying 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:
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:
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:
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []); // captures count === 0, foreverThe 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.
Related
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
varloop withsetTimeoutprint 3 3 3?” -varis function-scoped, so there is oneishared by all three callbacks, and by the time they run the loop has finished.letgets 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
#privateclass 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 ofsetStateavoids needing the captured value at all.