JavaScript Closures Explained (Without the Academic Jargon)
The loop bug, the private counter, and why your React useEffect is stale. A practical guide to closures for developers who just want to ship. Most explanations of closures start with "a closure is a function that captur
The loop bug, the private counter, and why your React useEffect is stale. A practical guide to closures for developers who just want to ship.
Most explanations of closures start with "a closure is a function that captures its lexical scope." That's true. It's also useless if you're debugging a React component at 11 PM.
Let's look at the three places closures actually matter: the loop bug, the private counter, and the stale useEffect.
But first, the core idea. In 60 seconds.
The Core Idea
A closure is a function that remembers the variables from where it was defined — even after that scope has finished running.
Here's the whole concept in one snippet:
function createGreeter(name) {
const greeting = "Hello";
return function () {
return greeting + ", " + name;
};
}
const greetAlice = createGreeter("Alice");
console.log(greetAlice()); // "Hello, Alice"
createGreeter has finished running. Its local variables (name, greeting) should be gone. But the returned function still has them.
That's a closure.
The mental model: When you define a function inside another function, the inner function carries a backpack. Every time it's called, it reaches into the backpack for name and greeting.
Bug #1: The Loop (The One That Gets Everyone)
This is the most common closure bug in JavaScript. If you've been coding for more than a year, you've probably hit it.
javascript
for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 100);
}
What you expect: 0, 1, 2
What you get: 3, 3, 3
Why?
var is function-scoped, not block-scoped. There is only one i for the entire loop. All three callbacks close over that same i. By the time the setTimeouts fire, the loop has finished, and i is 3.
The Fix
javascript
for (let i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 100);
}
What you expect: 0, 1, 2
What you get: 0, 1, 2
let is block-scoped. Each iteration creates a new i. Each callback gets its own backpack with its own i.
The takeaway: If you see a loop with a setTimeout and a var, it's probably a bug. Use let.
Bug #2: The Private Counter (The Pattern You Already Use)
You've probably used this pattern without knowing its name. It's the module pattern. It's how JavaScript implements private state before #privateFields.
javascript
function createCounter() {
let count = 0;
return {
increment() { count++; },
decrement() { count--; },
value() { return count; },
};
}
const counter = createCounter();
counter.increment();
counter.increment();
counter.increment();
console.log(counter.value()); // 3
What you can't do:
- counter.count = 100 — doesn't affect the internal count.
- delete counter.count — no effect.
- console.log(counter.count) — undefined.
There is no count _ property on _counter. The only way to touch it is through the three methods.
Why This Works
All three methods close over the same count. They share a backpack.
counter.increment() ────┐
counter.decrement() ────┼──→ [ count: 3 ]
counter.value() ────┘
Every call to createCounter() creates a new count and a new backpack.
javascript
const c1 = createCounter();
const c2 = createCounter();
c1.increment();
c1.increment();
c2.increment();
console.log(c1.value()); // 2
console.log(c2.value()); // 1
Two counters. Two backpacks. Zero shared state.
The takeaway: If you need state that no other code can touch, a closure is the tool. It's the reason Redux, Zustand, and every other state library uses this pattern internally.
Bug #3: The Stale Closure (The React One)
This is the version that bit me last week. It's the reason _useEffect _ dependency arrays exist.
jsx
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log("Interval sees count:", count);
}, 1000);
return () => clearInterval(id);
}, []); // ⚠️ Empty deps array
return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}
What you expect: The interval logs the current _count _ (0, then 1, then 2, ...).
What actually happens: It logs 0 forever.
Why?
The callback inside setInterval closes over count from the first render. It's a snapshot. When count updates, the interval still sees the old value — the backpack never changed.
The Fixes
Fix 1: Add count to the dependency array
jsx
useEffect(() => {
const id = setInterval(() => {
console.log("Interval sees count:", count);
}, 1000);
return () => clearInterval(id);
}, [count]); // ✅ Re-creates the interval when count changes
Now every time count changes, the effect re-runs with a fresh backpack.
Fix 2: Use the functional updater
If you're using count inside setCount, you don't need count in the closure at all:
jsx
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // ✅ Reads fresh value from React
}, 1000);
return () => clearInterval(id);
}, []); // ✅ Empty array is correct here
This is the fix I use most. It's cleaner and avoids the re-render churn.
The takeaway: If your useEffect callback uses a variable that changes, either add it to the deps array or use a functional updater. If the callback only uses setState or refs, the empty array is safe.
The Rule of Thumb
A closure captures the variable, not the value.
- If the variable is var, all closures share it.
- If the variable is let, each closure gets its own.
- If you mutate the variable after creating the closure, the closure sees the new value.
- If you rebind the variable (x = newThing), the closure still sees the old one unless it 's in a scope that saw the rebind.
That's it. That's the whole language feature.
When to Use Closures
- Private state. The counter pattern. No global variables.
- Callbacks. setTimeout, event handlers, Promise.then — they all rely on closures to remember the variables they need.
- Factory functions. createGreeter("Alice") returns a function. The "Alice" is baked in.
- Module pattern. Wrap a module in an IIFE. Everything inside is private, except what you export.
When Not to Use Closures
- When you need to break early. A closure can't break a loop from outside.
- When the closure would capture a huge object. Every captured variable stays in memory.
- When a plain function would do. If you don't need the outer scope, don't create a closure.
The 30-Second Summary
- A closure is a function that remembers its outer variables.
- var in loops shares the variable. let gives each iteration its own.
- Closures are how JavaScript implements private state.
- If your React useEffect sees stale values, add them to the deps array or use a functional updater.
That's everything I wish someone had told me when I was learning.
If you want the full breakdown — with the memory diagram, the interactive demo, and the "Why does this break when I pass a method as a callback?" follow-up — it's on my site:
👉 JavaScript Closures — The Full Guide
If this helped, consider sharing it with someone who's stuck on the loop bug. It's the one that keeps giving.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.