Closures are not theory. They are the reason your React state goes stale and your Node process leaks memory. Here are 6 patterns that fix real bugs using closures correctly.
1. Counter State Without Global Variables
The simplest closure use case still shows up in interviews and real code.
Before
let count = 0;
function increment() {
count++;
return count;
}
After
function createCounter() {
let count = 0;
return function increment() {
count++;
return count;
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
The state is now private and isolated per instance. No accidental mutation from outside. This is the foundation of every hook and factory pattern.
2. Fixing the Classic Loop Closure Bug
This bug still appears in production code.
Before
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 100);
}
// 3 3 3
After
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 100);
}
// 0 1 2
var creates one shared binding. Every closure points to the same memory. let creates a new binding per iteration, so each closure captures a different value.
3. Debounce With Persistent State
Closures are how you keep state between function calls without globals.
Before
function debounce(fn, delay) {
let timeout;
return function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), delay);
};
}
This is already correct best practice. The closure stores timeout.
After (improved with context + perf)
function debounce(fn, delay) {
let timeout;
return function (...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => {
fn.apply(context, args);
}, delay);
};
}
Now it preserves this and arguments correctly across calls. One closure replaces repeated timer allocations and reduces unnecessary executions.
4. Fixing React Stale Closure Bugs
This is where most developers fail interviews.
Before
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
console.log(count);
}, 1000);
}, []);
}
Logs 0 forever.
After
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(prev => {
console.log(prev);
return prev;
});
}, 1000);
return () => clearInterval(id);
}, []);
}
The fix uses a functional update, not a captured variable. The closure now references the latest state instead of the initial render.
This exact class of bug is why closures dominate interviews .
5. Avoiding Hidden Memory Leaks in Closures
Closures capture entire environments, not individual variables.
Before
function createProcessor() {
const hugeData = loadHugeData(); // 300MB
const map = buildMap(hugeData);
return function (id) {
return map[id];
};
}
hugeData stays in memory forever, even if unused.
After
function createProcessor() {
const map = (() => {
const hugeData = loadHugeData();
return buildMap(hugeData);
})();
return function (id) {
return map[id];
};
}
Now only map is captured. Memory drops dramatically. If you deal with long running services, this pattern compounds with the Node.js memory leaks detection and resolution guide when analyzing heap snapshots.
6. Memoization With Closure Cache
Closures are the simplest way to build a cache.
Before
function slowSquare(n) {
console.log("computing...");
return n * n;
}
After
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 fastSquare = memoize(slowSquare);
fastSquare(4); // computing...
fastSquare(4); // cached
The closure keeps cache alive across calls. This turns repeated O(n) work into O(1) lookups.
Closures are not a concept to memorize. They are a memory model. If you understand what gets captured and when, you can predict bugs before they ship.
Take one pattern here and apply it to your current codebase. The fastest way to level up is not learning new tools. It is fixing the bugs you already have.
Top comments (0)