- Published on
JavaScript Closures Explained - Once and For All
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Closures are one of JavaScript's most misunderstood concepts — and one of its most powerful. They're the mechanism behind React hooks, the module pattern, debouncing, memoization, and private state.
By the end of this guide, closures will feel completely natural.
- What is a Closure?
- A Practical Example: Counter
- The Classic Loop Problem
- Closures in Event Handlers
- Closures for Private State — Module Pattern
- Closures in React — useState
- Closure-Based Memoization
- Closure-Based Debounce
- Conclusion
What is a Closure?
A closure is a function that remembers the variables from its outer scope even after the outer function has returned.
function outer() {
const message = 'Hello!' // Variable in outer scope
function inner() {
console.log(message) // inner "closes over" message
}
return inner
}
const greet = outer() // outer() has returned
greet() // Still prints "Hello!" — why?
inner still has access to message because it formed a closure over it. The variable lives as long as there's a reference to inner.
A Practical Example: Counter
function createCounter(start = 0) {
let count = start // Private state!
return {
increment: () => ++count,
decrement: () => --count,
reset: () => { count = start },
getCount: () => count,
}
}
const counter = createCounter(10)
console.log(counter.increment()) // 11
console.log(counter.increment()) // 12
console.log(counter.decrement()) // 11
console.log(counter.getCount()) // 11
// count is private — can't access it directly!
console.log(counter.count) // undefined
count is private — only accessible through the returned methods. This is the Module Pattern!
The Classic Loop Problem
This trips up almost every JavaScript developer:
// ❌ Bug — all log '3'!
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100)
}
// 3, 3, 3 — NOT 0, 1, 2!
// Why? var is function-scoped, all closures share the SAME i
// ✅ Fix 1: Use let (block-scoped)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100)
}
// 0, 1, 2 ✅
// ✅ Fix 2: IIFE to capture value
for (var i = 0; i < 3; i++) {
((j) => {
setTimeout(() => console.log(j), 100)
})(i)
}
// 0, 1, 2 ✅
The let version creates a new binding for each iteration — each closure captures its own i.
Closures in Event Handlers
function setupButton(buttonName, message) {
const button = document.getElementById(buttonName)
// The handler closes over 'message' — different for each call
button.addEventListener('click', () => {
alert(message)
})
}
setupButton('btn1', 'Hello!')
setupButton('btn2', 'Goodbye!')
// Each button remembers its own message
Closures for Private State — Module Pattern
const userService = (() => {
// Private
let users = []
let nextId = 1
// Public API
return {
create(name, email) {
const user = { id: nextId++, name, email }
users.push(user)
return user
},
getAll() {
return [...users] // Return a copy — can't modify internal array
},
findById(id) {
return users.find(u => u.id === id)
},
delete(id) {
users = users.filter(u => u.id !== id)
}
}
})()
userService.create('Alice', 'alice@example.com')
userService.create('Bob', 'bob@example.com')
console.log(userService.getAll()) // Both users
// users array is completely private!
Closures in React — useState
React's useState hook is powered by closures:
function Counter() {
const [count, setCount] = useState(0)
// This function closes over 'count'
const handleClick = () => {
setCount(count + 1) // Uses the closed-over count
}
return <button onClick={handleClick}>{count}</button>
}
And the classic stale closure bug:
// ❌ Stale closure bug
function Timer() {
const [count, setCount] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1) // 'count' is closed over from initial render!
// Always increments from 0 → always stays at 1
}, 1000)
return () => clearInterval(interval)
}, []) // Empty deps — the closure captures the initial count
return <div>{count}</div>
}
// ✅ Fix: Use the functional update form
useEffect(() => {
const interval = setInterval(() => {
setCount(c => c + 1) // Uses the LATEST count, no closure issue
}, 1000)
return () => clearInterval(interval)
}, [])
Closure-Based Memoization
function memoize(fn) {
const cache = {} // Closed over by the returned function
return function(...args) {
const key = JSON.stringify(args)
if (key in cache) {
console.log('Cache hit!')
return cache[key]
}
cache[key] = fn(...args)
return cache[key]
}
}
const expensiveCalc = memoize((n) => {
console.log(`Computing ${n}...`)
return n * n
})
expensiveCalc(5) // Computing 5... → 25
expensiveCalc(5) // Cache hit! → 25 (no computation)
expensiveCalc(10) // Computing 10... → 100
Closure-Based Debounce
function debounce(fn, delay) {
let timer // Closed over!
return function(...args) {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
}
const handleSearch = debounce((query) => {
console.log(`Searching: ${query}`)
}, 500)
handleSearch('h')
handleSearch('he')
handleSearch('hel')
handleSearch('hell')
handleSearch('hello') // Only this one fires — 500ms after last call
Conclusion
Closures are everywhere in JavaScript — React hooks, event handlers, module patterns, debouncing, memoization. Once you understand that a function remembers the scope where it was created, closures stop being mysterious and start being your favorite tool. They're the key to encapsulation, private state, and elegant functional patterns in JavaScript.