Concept
The beginner framing: when a function is defined inside another function, it "closes over" the variables in the outer function, capturing them by reference, not by value.
The precise mental model: a closure is the combination of a function and its lexical environment, the set of variable bindings that were in scope at the point the function was created. The V8 engine represents this as a hidden [[Environment]] slot on every function object, pointing to the scope chain at creation time.
function makeCounter() {
let count = 0; // captured by the closure below
return function increment() {
count += 1;
return count;
};
}
const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3makeCounter has returned. Its stack frame is gone. But count is still alive because increment holds a reference to the scope that contains it. The GC won't collect it until all references to increment are dropped.
function makeCounter() {let count = 0;return function increment() {count += 1;return count;};}const counter = makeCounter();counter(); // ?counter(); // ?
makeCounter() runs. count = 0 lives in its own function scope.
Edge case: closures capture the binding, not the value
const fns = [];
for (var i = 0; i < 3; i++) {
fns.push(() => console.log(i));
}
fns[0](); // 3, not 0!
fns[1](); // 3
fns[2](); // 3var is function-scoped, so all three closures share the same i binding. By the time any function runs, the loop has finished and i === 3.
Fix with let (block-scoped, a fresh binding per iteration) or an IIFE:
for (let i = 0; i < 3; i++) {
fns.push(() => console.log(i)); // 0, 1, 2 ✓
}Try It
Predict what each call logs before running it.
function makeMultiplier(factor) {
return function (n) {
return n * factor;
};
}
const double = makeMultiplier(2);
const triple = makeMultiplier(3);
console.log(double(5));
console.log(triple(5));
console.log(double(10));Solution
10
15
20double and triple are two entirely separate closures, each call to makeMultiplier creates a brand-new scope with its own factor binding. They don't share state, even though they were created by the same function. This is what makes closures useful as a "function factory": each returned function is independently configured.
Implement It Yourself
Build a once utility, a function that wraps another function so it can only ever run one time, using a closure to remember whether it already has:
function once(fn) {
let called = false;
let result;
return function (...args) {
if (!called) {
result = fn(...args);
called = true;
}
return result;
};
}
const initialize = once(() => {
console.log("Initializing...");
return { ready: true };
});
initialize
called and result live in a scope that only the returned function can reach, there is no once.called property to accidentally reset from outside. This is the same private-state pattern behind useState, memoization utilities, and one-time setup guards you'll find throughout real codebases.
In React
Every function component body runs fresh on every render, and every function you define inside it, event handlers, effect callbacks, memoized functions, is a new closure over that specific render's props and state. This isn't incidental; it's the entire mechanism that makes hooks work at all:
function SearchBox({ onSearch }) {
const [query, setQuery] = useState("");
function handleSubmit() {
onSearch(query); // closes over THIS render's `query`
}
return <button onClick={handleSubmit}>Search</button>;
}handleSubmit doesn't read query from some shared mutable box, it closes over the exact query value from the render that created it. Click the button, and you get that render's query, guaranteed, even if a re-render with a different query happens moments later. This is also precisely why stale closures (see Common Mistakes below) exist as a category of React bug: a closure created in an earlier render, still alive because something (an effect, a timeout, an event listener) is holding onto it, will keep seeing that earlier render's values forever, it has no way to "catch up" to newer state.
Common Mistakes
1. The classic var in a loop (above)
Wrong: var in for-loops with async callbacks.
Fix: Use let, or wrap the callback in an IIFE that captures i by value.
2. Unintentional memory retention
function attachHandler(element) {
const heavyData = loadHeavyData(); // 50 MB object
element.addEventListener("click", () => {
console.log(heavyData.name); // only uses .name
});
}The entire heavyData object is kept alive as long as the event listener exists, even if you only need one property. Fix: close over only what you need.
function attachHandler(element) {
const name = loadHeavyData().name; // capture only what's needed
element.addEventListener("click", () => console.log(name));
}3. Treating closure state as private when it isn't
Closures give you encapsulation, not true privacy. The inner variable is inaccessible from outside, but whoever receives the function controls when and how often it runs, so "private" is relative to the caller's control.
4. Forgetting closures in React
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // ⚠️ stale closure, always reads initial count
}, 1000);
return () => clearInterval(id);
}, []); // missing count in deps
}Fix: use the functional updater form setCount(c => c + 1) or add count to deps (and clear/restart the interval).
Best Practices
- Capture the minimum. Extract only the values you need rather than letting large objects stay alive through a closure.
- Name your inner functions. Anonymous arrow functions make stack traces harder to read.
const increment = () => ...is better than a nameless() => ...in a returned position. - Prefer
let/constovervar. Block scoping eliminates the loop-closure gotcha by default. - In React: audit your
useEffectanduseCallbackdependency arrays, every variable from outer scope used in a callback is a potential stale closure.
Performance Tips
- Closures themselves have near-zero overhead. The hidden
[[Environment]]pointer is a single pointer indirection. - The cost is in retained memory. If a closure is long-lived (event listener, timer, cached function), the entire captured scope chain stays on the heap. Profile with Chrome DevTools heap snapshots, search for "Closure" in the heap summary to see what's retained and by what.
- V8 optimizes "dead" captured variables away via escape analysis, if a closure captures
xbut never reads it, V8 may not allocate the closure slot. Don't rely on this for correctness, but it's why micro-benchmarks of trivial closures look faster than expected.
