JavaScript Closures Demystified
You set up a setInterval inside a React useEffect, and the counter goes from 0 to 1 and then just sits there. Or a loop attaches five click handlers and every one of them logs the same index. Neither is a React bug or a browser bug. Both come down to closures, the thing most of us use every day without ever looking at how it works.
This post covers how scope lookup works, what a closure actually holds on to, the patterns I use closures for in real code, the loop and stale state bugs, and the memory side of things that tutorials usually skip.
A closure is a function that remembers its lexical environment even when it runs somewhere else. A function "closes over" the variables that were in scope where it was defined, not where it's called.
Understanding Scope First
Closures only make sense once scope does. Scope decides which variables a line of code can see. JavaScript uses lexical scoping (also called static scoping): what's visible depends on where the code sits in the source file, not on who called it at runtime.
The Three Types of Scope
- Global scope: Variables declared outside any function or block. Accessible everywhere in the program.
- Function scope: Variables declared inside a function with
var,let, orconst. Accessible only within that function. - Block scope: Variables declared with
letorconstinside{}blocks (loops, if statements, etc.).
const globalVar = "I'm global"; // Global scope
function outer() {
const outerVar = "I'm outer"; // Function scope
if (true) {
const blockVar = "I'm block"; // Block scope
console.log(blockVar); // ✓ accessible here
console.log(outerVar); // ✓ accessible here
console.log(globalVar); // ✓ accessible here
}
console.log(outerVar); // ✓ accessible here
// console.log(blockVar); // ✗ ReferenceError: out of block
}
// console.log(outerVar); // ✗ ReferenceError: out of function
The Scope Chain
When JavaScript looks up a variable, it checks the current scope first, then walks outward through each enclosing scope until it hits global. That path is the scope chain, and closures are built directly on it.
JavaScript's scope chain: inner scopes can access outer variable environments
What Exactly Is a Closure?
Every time you define a function inside another function, you get a closure. The inner function keeps a reference to the outer function's variable environment (called a Lexical Environment in the spec), even after the outer function has returned and its execution context has been removed from the call stack.
function makeGreeter(greeting) {
// `greeting` lives in makeGreeter's scope
return function(name) {
// This inner function "closes over" `greeting`
console.log(`${greeting}, ${name}!`);
};
}
const sayHello = makeGreeter("Hello");
const sayHowdy = makeGreeter("Howdy");
// makeGreeter has returned and its execution context is gone
// But the inner function still has access to `greeting`!
sayHello("Alice"); // "Hello, Alice!"
sayHowdy("Bob"); // "Howdy, Bob!"
sayHello("Carol"); // "Hello, Carol!" (greeting is still "Hello")
The interesting part: makeGreeter has finished executing by the time we call sayHello. Normally, you'd expect its local variables to be garbage-collected. But because the returned function holds a reference to greeting, JavaScript keeps that variable alive. Each call to makeGreeter creates a new, independent closure with its own copy of greeting.
Internally, when a function is created, JavaScript attaches a [[Environment]] slot to it pointing to the Lexical Environment where it was defined. When the function executes, it creates a new Lexical Environment whose outer reference is that captured environment. That's the closure chain.
You can see closures directly in Chrome DevTools. Set a breakpoint inside the inner function and look at the Scope panel: you'll get a Closure (makeGreeter) entry listing exactly which outer variables it captured. When a value looks wrong, check there before adding console.log calls.
Practical Closure Patterns
These are the patterns where I actually reach for closures in day to day code.
1. Function Factories
You call a function once with some configuration, and it hands back a smaller function with that configuration baked in. It's the most direct use of a closure.
// Multiplier factory
function makeMultiplier(factor) {
return (number) => number * factor;
}
const double = makeMultiplier(2);
const triple = makeMultiplier(3);
const tenTimes = makeMultiplier(10);
console.log(double(5)); // 10
console.log(triple(5)); // 15
console.log(tenTimes(5)); // 50
// Power factory for API endpoints
function makeApiCaller(baseUrl) {
return async function(endpoint, options = {}) {
const response = await fetch(`${baseUrl}${endpoint}`, options);
return response.json();
};
}
const githubApi = makeApiCaller("https://api.github.com");
const myApi = makeApiCaller("https://api.myapp.com/v2");
// Specialized callers; baseUrl is captured
const user = await githubApi("/users/octocat");
const posts = await myApi("/posts");
2. Data Privacy with the Module Pattern
Before ES modules, closures were the main way to get private state in JavaScript. You'll still see this in older codebases and in plenty of library bundles. The module pattern uses an IIFE (Immediately Invoked Function Expression) to encapsulate private variables and expose only a public API.
const counter = (() => {
// Private state, not accessible outside
let count = 0;
const history = [];
return {
increment(amount = 1) {
count += amount;
history.push({ action: 'increment', value: amount, result: count });
},
decrement(amount = 1) {
count -= amount;
history.push({ action: 'decrement', value: amount, result: count });
},
reset() {
count = 0;
history.push({ action: 'reset', result: 0 });
},
getCount() { return count; },
getHistory() { return [...history]; } // Return a copy
};
})();
counter.increment(5);
counter.increment(3);
counter.decrement(2);
console.log(counter.getCount()); // 6
console.log(counter.getHistory()); // [{action:'increment',...}, ...]
// Cannot access private state directly
console.log(counter.count); // undefined
console.log(counter.history); // undefined
3. Memoization
Memoization means caching a function's results so a repeat call with the same arguments skips the work. The cache lives in a closure, so nothing outside can touch it.
function memoize(fn) {
const cache = new Map(); // Closed over by the returned function
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log(`Cache hit for: ${key}`);
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
// Expensive recursive fibonacci: O(2^n) without memoization
function fib(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
const memoFib = memoize(fib);
console.time('first call');
memoFib(40); // Computes: slow, often around a second
console.timeEnd('first call');
console.time('second call');
memoFib(40); // Cache hit: practically instant
console.timeEnd('second call');
The first call above is just as slow as plain fib(40). Inside fib, the recursive calls go to fib, not memoFib, so only the top-level result gets cached. To memoize the recursion, the function has to call the memoized version of itself. Also watch the JSON.stringify key: it's fine for numbers and strings, but objects with different key order produce different keys, and the Map grows forever unless you cap it.
4. Partial Application and Currying
Partial application means fixing some of a function's arguments now and passing the rest later. Currying goes a step further and turns an n-argument function into n single-argument functions.
// Partial application
function partial(fn, ...presetArgs) {
return function(...laterArgs) {
return fn(...presetArgs, ...laterArgs);
};
}
function add(a, b, c) {
return a + b + c;
}
const add10 = partial(add, 10);
const add10and20 = partial(add, 10, 20);
console.log(add10(5, 3)); // 18
console.log(add10and20(7)); // 37
// Currying
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn(...args);
}
return function(...moreArgs) {
return curried(...args, ...moreArgs);
};
};
}
const curriedAdd = curry((a, b, c) => a + b + c);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6
The Classic Loop Gotcha
This one still shows up in code reviews: a var loop that schedules async callbacks.
// BUG: All callbacks print 5, not 0-4
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i); // Prints 5, 5, 5, 5, 5 !!
}, i * 100);
}
// Why? All five closures share the SAME `i` variable.
// By the time any callback fires, the loop has finished
// and i === 5. var has function scope, not block scope.
The bug exists because var is function-scoped, so all five iterations of the loop share the same i binding. The closures don't capture the value of i at the time of creation; they capture the variable itself. By the time the timeouts fire, i has been incremented to 5.
Three ways to fix it. In new code you only need the first one, but the other two show up in older projects:
Simplest modern fix: Replace var with let. Block-scoped let creates a new binding per iteration.
// let creates a new binding for each loop iteration
for (let i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i); // 0, 1, 2, 3, 4 ✓
}, i * 100);
}
// Each iteration has its own `i`, a genuinely separate variable.
// Under the hood, the engine re-initializes `i` each time
// and carries the previous value forward.
Classic ES5 fix: Wrap the callback in an IIFE to create a new scope per iteration, capturing the current value of i as a parameter.
for (var i = 0; i < 5; i++) {
(function(capturedI) {
setTimeout(function() {
console.log(capturedI); // 0, 1, 2, 3, 4 ✓
}, capturedI * 100);
})(i); // Immediately pass current i as argument
}
// The IIFE creates a new scope each iteration.
// `capturedI` is a fresh parameter binding with the current value.
// The inner closure captures capturedI, not the shared i.
Functional style: Use .bind() or pass i as an extra argument to setTimeout.
// Using Function.bind to pre-bind arguments
for (var i = 0; i < 5; i++) {
setTimeout(console.log.bind(null, i), i * 100); // 0,1,2,3,4 ✓
}
// Or pass extra args to setTimeout (less known but valid)
for (var i = 0; i < 5; i++) {
setTimeout(function(value) {
console.log(value); // 0, 1, 2, 3, 4 ✓
}, i * 100, i); // Third arg is forwarded to callback
}
Closures and Memory Management
Closures keep variables alive after the enclosing function returns. Usually that's the point. It turns into a leak when the closure itself lives a long time (an event listener, a timer, a cache entry) and holds on to something big.
// POTENTIAL MEMORY LEAK: Closure may retain large data
function attachHandler() {
const largeData = new Array(1_000_000).fill('x'); // several MB
document.getElementById('btn').addEventListener('click', function() {
// V8 only keeps variables that some closure in this scope
// references. If any other closure created here touches
// largeData, they all share one context, and this listener
// keeps largeData alive for as long as it's attached.
console.log('clicked');
});
}
// FIX: Don't close over what you don't need
function attachHandlerFixed() {
const largeData = new Array(1_000_000).fill('x');
const result = processData(largeData); // Extract only what you need
document.getElementById('btn').addEventListener('click', function() {
console.log('clicked, result:', result); // Only `result` is captured
});
// largeData is now eligible for GC after attachHandlerFixed() returns
}
// IMPORTANT: Also remove listeners when components unmount
function setupWithCleanup(element) {
const handler = () => console.log('clicked');
element.addEventListener('click', handler);
return () => element.removeEventListener('click', handler); // cleanup fn
}
The Comparison: With vs Without Closure
| Aspect | With Closure | Without Closure |
|---|---|---|
| Variable lifetime | Kept alive as long as the function reference exists | Freed when enclosing function returns |
| State isolation | Each closure instance has independent state | Shared via parameters or global state |
| Encapsulation | Private variables possible | Requires classes or other patterns |
| Memory footprint | Higher, since referenced vars aren't GC'd | Lower, since vars are freed after return |
| Testing | Can be harder to inspect private state | State passed explicitly, easier to test |
Closures in React Hooks
Every render of a function component creates fresh functions that close over that render's props and state. That's why linters warn about stale closures, and it's behind most of the confusing useEffect and useCallback bugs I run into.
import { useState, useEffect, useCallback } from 'react';
// STALE CLOSURE BUG
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// BUG: This closure captures `count` from the first render.
// count never changes inside this effect. It's always 0!
setCount(count + 1); // Always sets to 0 + 1 = 1
}, 1000);
return () => clearInterval(id);
}, []); // Empty deps: closure is created once and never refreshed
return <div>{count}</div>;
}
// FIX 1: Functional update form (no closure over `count`)
function CounterFixed() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // ✓ Uses current value, not closure
}, 1000);
return () => clearInterval(id);
}, []);
return <div>{count}</div>;
}
// FIX 2: Add count to deps array (effect re-registers each time count changes)
function CounterFixed2() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // ✓ count is always fresh
}, 1000);
return () => clearInterval(id);
}, [count]); // Re-run effect when count changes
return <div>{count}</div>;
}
// useCallback also creates closures, so include all used variables in deps
function SearchBox({ onSearch }) {
const [query, setQuery] = useState('');
const handleSearch = useCallback(() => {
onSearch(query); // Must include `query` in deps
}, [query, onSearch]); // ✓ Correct deps
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
The react-hooks/exhaustive-deps rule from eslint-plugin-react-hooks flags missing dependencies, which is how most stale closures happen. Keep it on. When it nags you, fix the dependency list or switch to a functional update instead of adding an eslint-disable comment.
Closures with Arrow Functions
Arrow functions create closures exactly like regular functions do. The difference is this: arrows don't get their own, so they pick it up from the enclosing scope the same way they'd pick up any other variable.
// Arrow functions close over `this` from the enclosing scope
class Timer {
constructor() {
this.seconds = 0;
}
start() {
// Regular function would lose `this` here
// Arrow function inherits `this` from start()'s scope
setInterval(() => {
this.seconds++; // ✓ `this` is the Timer instance
console.log(this.seconds);
}, 1000);
}
}
// Compare with a regular function, which needs .bind(this)
class TimerOld {
constructor() {
this.seconds = 0;
}
start() {
setInterval(function() {
// `this` would be `undefined` (strict mode) or `window`
// without .bind(this)
this.seconds++; // TypeError or wrong `this`!
}.bind(this), 1000); // Requires explicit binding
}
}
// Arrow function factories: short and idiomatic
const makeAdder = a => b => a + b; // Curried with arrow functions
const add5 = makeAdder(5);
console.log(add5(3)); // 8
console.log(add5(10)); // 15
Building a Practical Event Emitter
Here's a small event emitter that uses most of the above at once: a factory, private state, and returned functions that close over it. The on() method returns its own unsubscribe function, which is the same shape you'll see in Redux's subscribe and in useEffect cleanups.
function createEventEmitter() {
// Private: Map of event name → Set of listeners
const listeners = new Map();
function on(event, listener) {
if (!listeners.has(event)) {
listeners.set(event, new Set());
}
listeners.get(event).add(listener);
// Return an unsubscribe function (closure over `event` and `listener`)
return function off() {
listeners.get(event)?.delete(listener);
};
}
function once(event, listener) {
const wrapper = (...args) => {
listener(...args);
off(); // Remove after first invocation
};
const off = on(event, wrapper);
return off;
}
function emit(event, ...args) {
listeners.get(event)?.forEach(listener => {
try {
listener(...args);
} catch (err) {
console.error(`Error in ${event} listener:`, err);
}
});
}
function listenerCount(event) {
return listeners.get(event)?.size ?? 0;
}
// Public API; the private `listeners` Map stays hidden
return { on, once, emit, listenerCount };
}
// Usage
const bus = createEventEmitter();
const offLogin = bus.on('login', user => console.log(`${user} logged in`));
bus.once('ready', () => console.log('App is ready (fires once)'));
bus.emit('login', 'Alice'); // "Alice logged in"
bus.emit('ready'); // "App is ready (fires once)"
bus.emit('ready'); // (nothing, listener was removed)
offLogin(); // Unsubscribe
bus.emit('login', 'Bob'); // (nothing, listener removed)
Closure Patterns Quick Reference
| Pattern | What It Closes Over | Primary Use |
|---|---|---|
| Function Factory | Constructor arguments | Configurable, specialized functions |
| Module Pattern (IIFE) | Private state variables | Encapsulation, information hiding |
| Memoization | Cache (Map/Object) | Performance optimization |
| Partial Application | Pre-set arguments | Reusable specialized functions |
| Currying | Previously supplied args | Functional composition pipelines |
| Event Listener | DOM element or state | Callbacks with context |
| Unsubscribe / Cleanup | Subscription reference | Resource management |
Closure Mistakes I See Most Often
Most closure bugs I've had to track down fall into one of these four buckets.
Expecting a snapshot of the value
A closure holds a reference to the variable, not a copy of what it was at creation time. If the variable changes later, the closure sees the change. If it never gets re-created (an effect with empty deps), it never sees new values. Both the var loop bug and React's stale state come from this.
Stale state in long-lived callbacks
Intervals, WebSocket handlers and listeners registered once on mount are the usual suspects. Use a functional update, put the value in the dependency list, or keep the latest value in a useRef when you really need one long-lived callback.
Forgetting to clean up
A listener or timer that's never removed keeps its closure, and everything that closure references, alive. In single page apps this adds up as users move between views. Return a cleanup function and actually call it. Heap snapshots in DevTools will show you what's being retained if you suspect a leak.
Using var in loops with callbacks
Use let or const and the problem goes away. If you're stuck maintaining ES5 code that can't change, the IIFE fix still works.
When I wouldn't reach for a closure
- Private state in modern code: ES modules already give you file-level privacy, and classes have
#privatefields. The IIFE module pattern is mostly for legacy code now. - Lots of instances: Every factory call creates new function objects. With thousands of instances, a class with methods on the prototype uses less memory.
- State you need to inspect or test: Hidden state is hard to assert on. If tests keep needing a peek inside, pass the state explicitly instead.
- Unbounded caches: A memoize closure with no size limit is a slow memory leak on a long-running Node server. Use an LRU cache or a
WeakMapkeyed by objects.
You don't need to avoid closures; you can't really write JavaScript without them. Just remember what each one is holding on to and for how long, and most of the weird bugs above get a lot easier to spot.