JavaScript Closures Explained: Scope, Patterns, and Pitfalls
Understand JavaScript closures with practical examples: private state counters, module pattern, currying, memoization, and the classic var loop bug fix.
JavaScript Closures Explained
A closure is a function bundled together with references to its surrounding lexical scope. When a function is created in JavaScript, it silently captures the variable bindings that exist where it was defined, and it can access those bindings later, even after the outer function has finished executing. Closures are not a special feature you opt into; they are an automatic consequence of how JavaScript resolves identifiers.
The Core Idea
function outer() {
const message = 'hello';
return function inner() {
console.log(message);
};
}
const greet = outer();
greet(); // 'hello'Even though outer() has returned, inner still holds a live reference to message. The variable is kept alive on the heap because a closure needs it.
Counter With Private State
Closures give you true encapsulation without classes. The internal count cannot be reached from outside; the returned functions are the only interface.
function createCounter() {
let count = 0;
return {
increment: () => ++count,
decrement: () => --count,
value: () => count
};
}
const c = createCounter();
c.increment(); c.increment();
c.value(); // 2
c.count; // undefined — privateThe Module Pattern
Before ES modules, closures were the standard way to hide implementation details. An IIFE created a scope, and the returned object exposed only the public API.
const authModule = (function () {
let token = null;
function login(t) { token = t; }
function isAuthed() { return token !== null; }
return { login, isAuthed };
})();Currying
Currying transforms a multi-argument function into a chain of single-argument functions. Each returned function closes over the arguments captured so far.
const multiply = a => b => c => a * b * c;
multiply(2)(3)(4); // 24
const double = multiply(2)(1);
double(10); // 20Event Listener Capturing a Variable
function attach(label) {
document.getElementById('btn')
.addEventListener('click', () => {
console.log('clicked:', label);
});
}
attach('save');The handler outlives attach, yet label remains reachable because the listener closes over it.
Memoization
Cache expensive results in a Map that lives inside the closure. External code cannot tamper with the cache.
function memoize(fn) {
const cache = new Map();
return function (arg) {
if (cache.has(arg)) return cache.get(arg);
const result = fn(arg);
cache.set(arg, result);
return result;
};
}
const slowSquare = n => { /* heavy work */ return n * n; };
const fastSquare = memoize(slowSquare);The Classic var Loop Bug
This is the interview question every JavaScript developer eventually meets.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Logs: 3, 3, 3var is function-scoped, so every callback closes over the same i. By the time the timers fire, the loop has finished and i equals 3.
Fix 1: use let
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Logs: 0, 1, 2let creates a fresh binding per iteration, so each callback closes over its own i.
Fix 2: IIFE (the pre-ES6 workaround)
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(() => console.log(j), 100);
})(i);
}Memory Implications
A closure keeps every variable it can reach alive for as long as the closure itself is reachable. Common leaks:
- Attaching a handler that captures a huge DOM subtree and never removing it.
- Storing closures in a long-lived cache or global map.
- Returning a small function from a factory that captured large arrays purely as a side effect of the outer scope.
Modern engines perform escape analysis and only retain variables the inner function actually references, but you should still null out large objects before returning if they were used only during setup.
React Hooks Are Closures
Every render, React calls your component function. useState returns a state value and a setter, and your event handlers close over that snapshot. That is why a stale value can appear inside a setTimeout callback:
function Counter() {
const [n, setN] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// Closes over n from the render where the effect ran
console.log(n);
}, 1000);
return () => clearInterval(id);
}, []); // empty deps — n is frozen at 0
}The fix is either to include n in the dependency array or to use the functional updater form setN(prev => prev + 1), which reads from the latest state rather than the captured one.
Takeaways
- Closures happen automatically whenever a function references outer variables.
- Use them for private state, encapsulation, currying, and caches.
- Prefer
letandconstto avoid the shared-binding trap in loops. - Remember that captured variables live as long as the closure does.