Ask a junior developer to explain closures, and you'll likely get a nervous laugh. Ask a senior developer, and you might get a technically correct but confusing answer. Yet closures are everywhere in JavaScript — they're the invisible force behind React Hooks, event handlers, and countless design patterns.
The truth? Closures are simpler than you think, once you understand lexical scope.
The Golden Rule
A closure is a function that "remembers" variables from its outer (lexical) scope, even after that outer function has finished executing.
In other words: Functions carry their birthplace with them.
Let's build this understanding from the ground up.
Part 1: Understanding Lexical Scope First
Before closures, you need to understand lexical scope (also called static scope).
What is Lexical Scope?
Lexical scope means that variable accessibility is determined by where functions are defined in the code, not where they are called.
const global = 'I am global';
function outer() {
const outerVar = 'I am outer';
function inner() {
const innerVar = 'I am inner';
console.log(global); // Accessible
console.log(outerVar); // Accessible
console.log(innerVar); // Accessible
}
inner();
}
outer();
Output:
I am global
I am outer
I am inner
Key Point: The inner() function can access:
- Its own variables (
innerVar) - Variables from
outer()(outerVar) - Global variables (
global)
This chain is called the scope chain. JavaScript searches for variables starting from the innermost scope and working outward.
Lexical Scope is Determined at "Write Time," Not "Run Time"
const name = 'Outer';
function showName() {
console.log(name); // Looks for 'name' where showName was DEFINED
}
function wrapper() {
const name = 'Inner';
showName(); // Called here, but uses outer 'name'
}
wrapper();
Output:
Outer
Why? showName() was defined in the global scope, so it looks for name in the global scope — even though it's called from inside wrapper().
Part 2: What is a Closure?
A closure happens when a function retains access to its lexical scope even after the outer function has returned.
Classic Closure Example
function createCounter() {
let count = 0; // Private variable
return function increment() {
count++;
console.log(count);
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3
What just happened?
-
createCounter()ran and finished executing - Normally,
countwould be garbage-collected - But
increment()still has a reference tocount— this is a closure - Every time we call
counter(), it accesses the samecountvariable
Key Insight: The increment function "closed over" the variable count.
Why This Matters: Data Privacy
In JavaScript, there are no true private variables (until ES2022's # syntax). Closures are the classic workaround:
function createBankAccount(initialBalance) {
let balance = initialBalance; // Private!
return {
deposit(amount) {
balance += amount;
return balance;
},
withdraw(amount) {
if (amount > balance) {
throw new Error('Insufficient funds');
}
balance -= amount;
return balance;
},
getBalance() {
return balance;
}
};
}
const account = createBankAccount(100);
console.log(account.getBalance()); // 100
account.deposit(50); // 150
account.withdraw(30); // 120
// No way to directly access 'balance' from outside!
console.log(account.balance); // undefined
Result: The balance variable is encapsulated — only the returned methods can access it.
Part 3: Common Closure Patterns
Pattern 1: Function Factory
function createMultiplier(multiplier) {
return function (number) {
return number * multiplier;
};
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
Each function remembers its own multiplier value.
Pattern 2: Event Handlers with Closures
function setupButtons() {
for (let i = 1; i <= 3; i++) {
const button = document.createElement('button');
button.textContent = `Button ${i}`;
button.addEventListener('click', function() {
console.log(`Button ${i} clicked`); // Closure over 'i'
});
document.body.appendChild(button);
}
}
setupButtons();
Result: Clicking each button logs the correct number (1, 2, or 3).
Why it works: let creates a new binding for each iteration, and the click handler closes over that specific i.
⚠️ Common Mistake: Using var Instead of let
function setupButtonsWrong() {
for (var i = 1; i <= 3; i++) { // Using 'var'
const button = document.createElement('button');
button.textContent = `Button ${i}`;
button.addEventListener('click', function() {
console.log(`Button ${i} clicked`);
});
document.body.appendChild(button);
}
}
setupButtonsWrong();
Result: All buttons log Button 4 clicked (the final value of i after the loop).
Why? var is function-scoped, not block-scoped. All click handlers share the same i variable.
Fix: Use let (block-scoped) or create a closure manually:
for (var i = 1; i <= 3; i++) {
(function(j) { // IIFE creates new scope
button.addEventListener('click', function() {
console.log(`Button ${j} clicked`);
});
})(i);
}
Part 4: Closures in React
React Hooks are built on closures. Understanding this is critical for avoiding bugs.
1. useState and Closures
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // 'count' is captured by closure
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
</div>
);
}
How it works:
- Each render creates a new
handleClickfunction - That function closes over the current value of
count - When you click the button, it uses the
countfrom that render
2. The Stale Closure Problem
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(`Count: ${count}`); // Always logs 0!
setCount(count + 1); // Always sets to 1!
}, 1000);
return () => clearInterval(id);
}, []); // Empty deps = only runs once
return <div>{count}</div>;
}
Problem: The setInterval callback closes over the initial count value (0) because the effect only runs once.
Solution 1: Use functional updates
setCount(prevCount => prevCount + 1); // Uses current state
Solution 2: Add count to dependencies
useEffect(() => {
const id = setInterval(() => {
console.log(`Count: ${count}`); // Updates every time count changes
}, 1000);
return () => clearInterval(id);
}, [count]); // Re-run effect when count changes
Trade-off: Solution 2 restarts the interval every second (might not be desired). Solution 1 is usually better for this case.
3. Event Handlers and Closures
function UserList({ users }) {
const handleClick = (userId) => {
console.log(`Clicked user: ${userId}`);
};
return (
<ul>
{users.map(user => (
<li key={user.id} onClick={() => handleClick(user.id)}>
{user.name}
</li>
))}
</ul>
);
}
What's happening:
- Each
onClickhandler is a new function (created on every render) - Each closes over the specific
user.idfrom that iteration
Performance note: If UserList re-renders frequently, you might memoize the handler:
const handleClick = useCallback((userId) => {
console.log(`Clicked user: ${userId}`);
}, []); // Doesn't depend on any props/state
4. Custom Hooks and Closures
function useInterval(callback, delay) {
const savedCallback = useRef();
// Remember the latest callback
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval
useEffect(() => {
function tick() {
savedCallback.current(); // Always calls the latest callback
}
if (delay !== null) {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}
Why this pattern?
- The
setIntervalcallback closes oversavedCallback.current - We update
savedCallback.currentevery render - This ensures the interval always calls the latest callback, avoiding stale closures
Usage:
function Counter() {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(count + 1); // 'count' is always current!
}, 1000);
return <div>{count}</div>;
}
5. Closures in Context API
const ThemeContext = React.createContext();
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => { // Closure over 'theme' and 'setTheme'
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
};
const value = { theme, toggleTheme }; // New object every render
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
Optimization: Create the value object with useMemo to prevent unnecessary re-renders:
const value = useMemo(() => ({ theme, toggleTheme }), [theme]);
Part 5: Advanced Closure Concepts
Memory Leaks with Closures
Closures can prevent garbage collection if you're not careful:
function createHugeArray() {
const hugeArray = new Array(1000000).fill('data');
return function() {
console.log(hugeArray[0]); // Closure keeps entire array in memory!
};
}
const fn = createHugeArray(); // 'hugeArray' can't be garbage-collected
Fix: Only close over what you need:
function createHugeArray() {
const hugeArray = new Array(1000000).fill('data');
const firstElement = hugeArray[0]; // Extract what you need
return function() {
console.log(firstElement); // Only keeps 'firstElement', not entire array
};
}
Closures and this Keyword
const obj = {
name: 'Object',
regularFunction: function() {
setTimeout(function() {
console.log(this.name); // 'this' is undefined (or global object)
}, 1000);
},
arrowFunction: function() {
setTimeout(() => {
console.log(this.name); // 'this' is lexically bound to obj
}, 1000);
}
};
obj.arrowFunction(); // Logs 'Object'
Why? Arrow functions don't have their own this — they close over the this value from their outer scope.
Quick Reference Cheat Sheet
| Concept | Explanation | Example Use Case |
|---|---|---|
| Lexical Scope | Variable access determined by where functions are defined | Understanding scope chains |
| Closure | Function remembers its outer scope after outer function returns | Data privacy, callbacks |
| Stale Closure | Closure captures old variable values | Common bug in useEffect
|
| Functional Updates |
setState(prev => ...) to avoid stale closures |
Counters, intervals |
useCallback |
Memoizes functions to prevent new closures every render | Performance optimization |
useRef |
Mutable container that doesn't cause re-renders | Storing latest callback |
Key Takeaways
Lexical scope is determined at write time, not run time
Closures allow functions to access outer variables even after the outer function has returned
Use let in loops, not var, to create proper closures
React Hooks rely on closures — useState, useEffect, and custom hooks all use them
Stale closures are a common bug — fix with functional updates or proper dependencies
Closures can cause memory leaks if you close over large data structures unnecessarily
Arrow functions lexically bind this by closing over the outer this value
Interview Tip
When asked about closures, explain them in three steps:
- "A closure is when a function remembers variables from its outer scope"
- Give a simple example (like a counter)
- Explain a practical use case (React Hooks, data privacy, or event handlers)
Then mention: "In React, understanding closures is critical because Hooks like useState and useEffect create closures over props and state. If you don't handle dependencies correctly, you can get stale closures."
Now go forth and never fear the closure question again!
Top comments (0)