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.
Top comments (0)