JavaScript Closures Explained: Why Your Functions Remember Everything
Why does this print 3, 3, 3? Why does React keep using old state after you update it? The answer is always closures - and one insight unlocks all of it: functions capture variable references, not their values.

Article focus
Lexical scope
determines what closures capture
Key takeaways
- Closures capture references to variables, not their values - this single rule explains the var loop bug, stale React hooks, and every closure memory leak.
- JavaScript uses lexical scope - what a function can access is determined by where it was written in source code, not where it is called.
- The classic var loop bug: all callbacks share one variable reference and read the final value after the loop ends, not the value at creation time.
- React hooks are closures - stale closures (capturing old state at mount time) are the most common source of subtle hook bugs.
- Closures keep outer scope variables alive as long as the closure exists - always clean up event listeners and subscriptions.
What Is a Closure?
A closure is a function that retains live references to variables from its enclosing scope - not copies of their values at creation time, but the actual variables themselves, kept alive even after the outer function returns.
Here is a snippet everyone gets wrong on their first try: `for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); }`. Most developers expect 0, 1, 2. They get 3, 3, 3. Same pattern appears in React - a state value always one render behind, a timer reading stale data. The root cause in every case is the same thing.
Closures capture references to variables, not snapshots of their values. That callback does not freeze the value of i at the moment it is created - it holds a live pointer to i itself. By the time the callbacks fire, the loop has moved i to 3 and every callback reads 3 from the same reference. This distinction between reference and value is the single insight that makes async JavaScript predictable.
This is not a feature you opt into. Every function in JavaScript is a closure. Functions carry their entire scope chain with them wherever they go. The counter below proves it - count survives makeCounter returning because increment holds a live reference to it, not a snapshot.
javascript
function makeCounter() {
let count = 0; // this variable lives in makeCounter's scope
return function increment() {
count++; // increment closes over 'count'
return count;
};
}
const counter = makeCounter(); // makeCounter has returned
counter(); // 1 - count is still alive because increment holds a reference
counter(); // 2
counter(); // 3
// count is NOT accessible from outside
console.log(typeof count); // "undefined"Try It: Independent Closures
Click each counter. Both use the same makeCounter() factory but each closure has its own private count variable.
Both counters come from the same makeCounter() factory, but each closure has its own private count. Click the buttons to see them stay independent.
const counterA = makeCounter()
const counterB = makeCounter()
counterA.count = 0 | counterB.count = 0
Lexical Scope: Where You Write Determines What You Can See
Lexical scope means a function's scope is determined at write time, based on where the function appears in the source code - not at call time, based on where it is invoked.
JavaScript's scope chain is built at parse time, based on code structure - not at call time. When the engine looks up a variable, it walks outward from the current function to its parent, then its parent's parent, all the way to global. Because this chain is fixed by where you write the function, a closure is always predictable: it sees the same outer variables no matter how or where it is called.
Nested functions can always see variables in all their enclosing scopes. Outer functions cannot see into inner functions. Scope flows outward, never inward.
javascript
const outerVariable = 'I am outer';
function outer() {
const middleVariable = 'I am middle';
function middle() {
const innerVariable = 'I am inner';
function inner() {
// Can see all enclosing scopes (lexical chain)
console.log(outerVariable); // ✓
console.log(middleVariable); // ✓
console.log(innerVariable); // ✓
}
inner();
// Can NOT see innerVariable here - scope only flows outward
}
middle();
// Can NOT see innerVariable or middleVariable from inner
}
outer();The Classic Loop Bug: var vs let
With var, all loop callbacks close over the same variable reference because var is function-scoped. With let, each iteration creates a new binding, so each callback captures its own copy.
This is probably the most taught closure bug. You create event listeners or async callbacks inside a loop expecting each one to capture the current loop index - but they all print the final value instead.
The reason: var is hoisted to the function scope, so there is only one count variable shared by all iterations. By the time the callbacks run, the loop has finished and count holds its final value.
let creates a new binding per iteration because it is block-scoped. Each loop body is a new block, so each closure captures a distinct variable. This is not a quirk - it is by design in the ES6 spec.
javascript
// BUG: var is function-scoped - all callbacks share the same 'i'
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Output: 3, 3, 3
// FIX 1: let creates a new binding per iteration
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Output: 0, 1, 2
// FIX 2 (pre-ES6): IIFE to capture each value
for (var i = 0; i < 3; i++) {
(function(captured) {
setTimeout(() => console.log(captured), 100);
})(i);
}
// Output: 0, 1, 2
// FIX 3: use forEach (each callback has its own parameter)
[0, 1, 2].forEach(i => {
setTimeout(() => console.log(i), 100);
});
// Output: 0, 1, 2Try It: var vs let Loop Bug
Toggle between var and let, predict the output, then click Run to see exactly what closures capture.
Toggle between var and let, then predict the output before clicking Run.
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); }
Closures in React: useCallback and the Stale Closure Problem
React hooks are closures over component state. When a hook callback captures a variable from component scope, it captures the value at the time it was created - not the latest value. This causes the "stale closure" bug.
Every time a component renders, new closures are created. An effect callback captures the state values from that render's closure. If the dependency array is empty, the effect only runs once - and its closure holds values from the initial render forever.
This explains why the React docs are insistent about dependency arrays. An empty dependency array means "run once, with the values from mount." A complete dependency array means "re-create the closure when these values change."
javascript
// STALE CLOSURE BUG
function SearchBox() {
const [query, setQuery] = useState('');
useEffect(() => {
// This closure captures 'query' from the first render (empty string)
const id = setInterval(() => {
console.log('Searching for:', query); // Always '' - stale!
fetchResults(query);
}, 2000);
return () => clearInterval(id);
}, []); // Empty deps = closure never refreshes
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
// FIX 1: Add query to dependency array
useEffect(() => {
const id = setInterval(() => {
fetchResults(query); // Always fresh
}, 2000);
return () => clearInterval(id);
}, [query]); // Re-creates closure when query changes
// FIX 2: Use a ref to always get the latest value
const queryRef = useRef(query);
useEffect(() => { queryRef.current = query; }, [query]);
useEffect(() => {
const id = setInterval(() => {
fetchResults(queryRef.current); // Ref is always current
}, 2000);
return () => clearInterval(id);
}, []); // Safe to omit from deps - ref is mutableMemoization: Caching Results with Closures
Memoization stores the results of expensive function calls in a closure-owned cache, returning the cached result when the same inputs are passed again.
The cache object lives in the closure. Each call to the memoized function checks the cache first. If a cached result exists, return it immediately without re-running the expensive computation.
This is exactly how React.useMemo works internally. It stores the previous result in a closure-like structure and only recomputes when dependencies change.
javascript
function memoize(fn) {
const cache = new Map(); // lives in closure
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key); // return cached result
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
// Expensive Fibonacci - without memoization: O(2^n)
const fib = memoize(function(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2); // recursive calls also hit cache
});
console.time('fib(40)');
console.log(fib(40)); // 102334155
console.timeEnd('fib(40)'); // <1ms instead of seconds
// React equivalent
const expensiveValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);Memory Implications: When Closures Cause Leaks
Closures keep their entire scope chain alive. If a closure references a large object and outlives its usefulness (e.g., stored in a long-lived cache), that object cannot be garbage collected.
The most common closure memory leak: an event listener that closes over a large DOM element or data structure, and is never removed. The listener's closure keeps the element alive even after it's removed from the DOM.
React's useEffect cleanup function is specifically designed to address this. Always return a cleanup function that removes event listeners, cancels subscriptions, and clears timers.
javascript
// MEMORY LEAK: listener closes over 'largeData', never removed
function setup() {
const largeData = new Array(1_000_000).fill('data');
document.addEventListener('click', function handler() {
// Uses largeData - keeps it alive forever
process(largeData);
});
// 'handler' is never removed - largeData can never be GC'd
}
// FIX: Remove listener when done
function setup() {
const largeData = new Array(1_000_000).fill('data');
function handler() {
process(largeData);
}
document.addEventListener('click', handler);
return function cleanup() {
document.removeEventListener('click', handler); // largeData now GC-eligible
};
}
// React: useEffect cleanup
useEffect(() => {
const handler = (e) => handleEvent(e);
window.addEventListener('resize', handler);
return () => {
window.removeEventListener('resize', handler); // cleanup on unmount
};
}, []);Try It: Leak vs Cleanup
Hire a helper (listener) and watch what happens to its data. No cleanup = boxes pile up forever. With cleanup = memory is freed when the job is done.
❌ No cleanup - helpers never leave
Every click hires a new helper holding a 1 MB box. Nobody ever tells them to leave. Boxes pile up and there’s no way to free them.
No helpers yet - press the button
✓ With cleanup - helper is dismissed
The helper holds the box while active. When you dismiss them, they put the box down. The browser can now throw the box away and reclaim the memory.
No helper hired yet
In React, this is exactly what useEffect’s cleanup function does - it dismisses the helper when the component unmounts so memory doesn’t pile up.
Debugging Closures in Chrome DevTools
Set a breakpoint inside a closure, then inspect the "Closure" section in the Scope panel on the right. DevTools shows all variables captured from enclosing scopes.
Open Chrome DevTools → Sources → set a breakpoint inside a function. When execution pauses, look at the Scope panel. You'll see sections for Local, Closure, and Global. The Closure section lists every variable the function has captured from outer scopes.
This is invaluable for debugging stale closure bugs. Pause inside a useEffect callback and check the Closure section - you'll see exactly which render's values were captured.
Test Your Understanding
1.What does this code print?
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}2.What is lexical scope?
3.What causes a stale closure in a React useEffect?
4.What does a closure capture - the value of a variable or a reference to it?
let count = 0; const getCount = () => count; count = 42; console.log(getCount()); // What prints?
5.A closure keeps a reference to a 1 MB DOM node in an event listener that is never removed. What happens?
On this site
These pages expand on how I work with teams, what I ship, and how to hire me for the same kind of execution.
Recommended blogs
Continue reading
How JavaScript Actually Executes Your Code: Event Loop & Call Stack Deep Dive
JavaScript call stack, event loop, microtask queue, and macrotask queue explained with real execution traces. Learn why Promises beat setTimeout and how to debug async timing bugs.
MDN Web Docs - The Event Loop
Read article
Onboard to Any Git Repo Fast: 5 Commands Before I Read Code
Five git commands I run before reading any code: find churn hotspots, map ownership risk, spot bug clusters, read delivery cadence, and detect firefighting patterns in minutes.
Photo from Pexels
Read article