Practical Uses of Closures
Closures power data privacy, memoization, and the module pattern. But they keep parent scopes alive in memory, so clean up when done.
Use closures for private variables (module pattern), results (memoization), and . Set closure references to null when no longer needed to free memory.
EXERCISE
1Data Hiding with Closures
Closures let you create truly private variables. No one outside the closure can read or modify them directly. This is JavaScript version of private fields.
A variable inside a function is unreachable from outside it. Combine that with a closure and you have private state that only chosen functions can touch, which is the basis of the module pattern.
function createCounter() {
let count = 0;
return {
increment() {
count += 1;
return count;
},
reset() {
count = 0;
return count;
},
value() {
return count;
},
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.value()); // 2
console.log(counter.count); // undefined
The returned object has three methods and no count property. There is no path from the outside to the variable, so it cannot be read, written, or accidentally deleted by unrelated code.
Compare that with the obvious alternative:
const counter = { count: 0 };
counter.count = "banana";
Nothing prevents this. Any code holding the object can put anything in the property, and the failure will surface later in whatever reads it.
Privacy also lets the accessor do work:
function createTemperature() {
let celsius = 0;
return {
set(value) {
if (typeof value !== "number") return "not a number";
celsius = value;
return celsius;
},
fahrenheit() {
return celsius * 9 / 5 + 32;
},
};
}
const temp = createTemperature();
console.log(temp.set(100)); // 100
console.log(temp.fahrenheit()); // 212
console.log(temp.set("hot")); // "not a number"
console.log(temp.fahrenheit()); // 212, unchanged
fahrenheit is derived on demand rather than stored, so it can never disagree with celsius. And the rejected value left the state untouched, because the only way in runs the check first.
This is the same encapsulation a class gives you, built from nothing but a function and the scope rules from unit 3.
The pattern generalises to anything with rules about how its state may change. A queue that must never go negative, a connection that must be opened before use, a form that must be validated before submission: in each case the state lives in the closure and the returned functions are the only way to reach it.
function createQueue() {
const items = [];
return {
add(item) {
items.push(item);
return items.length;
},
take() {
if (items.length === 0) return null;
return items.shift();
},
size() {
return items.length;
},
};
}
const q = createQueue();
q.add("a");
q.add("b");
console.log(q.take()); // "a"
console.log(q.size()); // 1
take on an empty queue returns null instead of undefined from a failed shift, because the function gets to decide what empty means. Exposing items directly would hand that decision to every caller.
EXERCISE
2Memoization: Remembering Past Results
Memoization is a technique where a function remembers its previous results. If called with the same input again, it returns the cached result instead of recalculating.
Memoization is caching a function's results so that repeating an input does not repeat the work. A closure is the natural place to keep that cache, because it needs to survive between calls and stay out of reach of everything else.
function memoizedSquare() {
const cache = {};
return function (n) {
if (n in cache) {
return "cached: " + cache[n];
}
cache[n] = n * n;
return "computed: " + cache[n];
};
}
const square = memoizedSquare();
console.log(square(4)); // "computed: 16"
console.log(square(4)); // "cached: 16"
console.log(square(5)); // "computed: 25"
cache lives in the closure. It persists across calls because square still refers to it, and it is private because nothing else does.
The saving only matters when the work is expensive. A naive Fibonacci makes the point:
function fib(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
console.log(fib(30)); // 832040
That recomputes the same values enormously many times, because fib(30) calls fib(29) and fib(28), and fib(29) calls fib(28) again, and so on down. The number of calls grows exponentially.
function makeFib() {
const cache = {};
return function fib(n) {
if (n <= 1) return n;
if (n in cache) return cache[n];
cache[n] = fib(n - 1) + fib(n - 2);
return cache[n];
};
}
const fib = makeFib();
console.log(fib(30)); // 832040
Each value is now computed once and read thereafter, so the work grows in proportion to n rather than exploding. The named function expression matters here: fib inside the body refers to the memoized version, so the recursive calls hit the cache too.
Two cautions before you apply this everywhere. The cache only helps for a pure function, one whose output depends on nothing but its arguments; caching something that reads changing state will return stale answers. And an unbounded cache grows forever, which is the subject of the next exercise.
Pro Tip:
n in cacheis the right check rather thanif (cache[n]). A legitimately cached0is falsy, so the truthiness test would recompute it every time, which is exactly the input a Fibonacci cache hits most.
EXERCISE
3Closure Pitfalls: Memory and Garbage Collection
Closures are powerful, but they come with trade-offs. Every closure keeps its parent scope alive in memory. If you are not careful, this can lead to memory issues.
A closure keeps its captured variables alive for as long as the closure itself is reachable. That is the feature. It is also the failure mode, because a function you forgot about can hold a large object in memory indefinitely.
frees memory automatically, but only for values nothing can reach any more. A closure counts as something that can reach.
function attach() {
const bigData = new Array(1000000).fill("x");
return function () {
return bigData.length;
};
}
const handler = attach();
attach has returned, but bigData cannot be freed, because handler refers to it. One million entries stay in memory for as long as handler does. That is correct behaviour and exactly what you asked for; the problem is when you did not mean to ask.
The common version of this is an event listener that is never removed:
function setupPanel(data) {
const button = document.getElementById("refresh");
button.addEventListener("click", function () {
console.log(data.length);
});
}
Every call to setupPanel adds another listener, and every listener holds its own data. Call it on each navigation in a single-page app and nothing is ever released. The page gets slower over an hour of use and no single line looks wrong.
function setupPanel(data) {
const button = document.getElementById("refresh");
function onClick() {
console.log(data.length);
}
button.addEventListener("click", onClick);
return function cleanup() {
button.removeEventListener("click", onClick);
};
}
const cleanup = setupPanel(rows);
cleanup();
Naming the handler makes it removable, and returning a cleanup function gives the caller a way to say "done". Once the listener is gone and cleanup is discarded, data is unreachable and can be collected.
Capture the smallest thing that does the job:
function attach(record) {
const id = record.id;
return function () {
return id;
};
}
The closure now holds a single value instead of the whole record. record may be large and may itself reference other objects; keeping only id lets everything else go.
Pro Tip: Any long-lived closure is worth a second look: listeners, timers, subscriptions, caches. The question is not whether it works, but what it is keeping alive, and for how long.