Closures
Date: 2026-09-28
A function keeps a live reference to the scope it was created in — not a snapshot of the values. That one fact explains private state, the
var-in-a-loop bug, React’s stale closures and a good share of memory leaks.
A closure is a function together with a reference to the environment it was defined in, kept alive for as long as the function itself is reachable.
The model
Every function call creates an environment record — the table of that call’s local bindings. Every function object carries a hidden link to the record that was current when the function was created (the spec calls it [[Environment]]). Looking up a name walks outward along those links.
makeCounter() called counter.increment() called later
│
┌─ env record: makeCounter #1 ─┐ │ new record for increment's own locals
│ count: 0 → 1 → 2 │ ◄────────────┘ outer link → makeCounter #1
└──────────────────────────────┘ (still alive: something points at it)
▲ ▲
increment get ← both functions share the SAME record
What survives isn’t the values — it’s the binding. Two functions created in the same call share one record, so a write through one is seen by the other.
The load-bearing sample:
function makeCounter() {
let count = 0; // lives in makeCounter's environment record
return {
increment: () => ++count, // captures the binding, not the number 0
get: () => count,
};
}
const a = makeCounter();
const b = makeCounter(); // a separate call → a separate record
a.increment(); a.increment();
a.get(); // 2
b.get(); // 0 ← each call made its own private countNothing outside can reach count. That is the only true privacy JavaScript had before #private class fields, and it’s still the lighter-weight option.
What it’s used for
- Private state — the counter above; the old module pattern (an IIFE, immediately invoked function expression, returning an object)
- Factories and configuration —
const log = makeLogger('checkout')bakes in the prefix once - Partial application —
const add5 = x => add(5, x) - Memoisation — a cache variable closed over by the function that consults it
- Callbacks that need context — every event handler or
setTimeoutthat refers to a variable from where it was set up
Where it goes wrong
var in a loop — one binding, shared. var is function-scoped, so the loop has a single i and every callback sees its final value. let creates a fresh binding per iteration — see Scope and Hoisting.
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 2Stale closures in React. Each render is a new call of the component function, so it creates a new environment record. A callback created in render 1 and still held in render 5 reads render 1’s values. This is the “why is my count one behind” bug — Reactivity.
function Ticker() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000); // count is forever 0
return () => clearInterval(id);
}, []); // ← effect never re-created
// fix: setCount(c => c + 1) — read the current value, don't close over it
}Memory retention. A closure keeps its whole environment chain reachable. In V8, closures created in the same scope share one context object, so a large variable captured by one closure stays alive while any sibling closure lives, even one that never uses it. An event listener that is never removed pins its component’s closure, and through it the component’s tree — Memory and Long Sessions, Garbage Collection.
Per-instance functions. A closure created per object (an arrow function in a constructor or class field) is one function per instance, not one shared on the prototype. That’s irrelevant for a component and measurable for a hundred thousand rows — Prototypes and Inheritance.
vs other languages
- PHP — closures capture by value unless you ask:
function () use (&$count). Arrow functions (fn) capture by value automatically. The JS default (by reference, always) is the opposite - Python — reads outer variables freely but needs
nonlocalto rebind one