DEV Community

Cover image for JavaScript Closures Explained (Without the Academic Jargon)
Khawaja Khurram (MAK)
Khawaja Khurram (MAK)

Posted on Originally published at makuistudio.com

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 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"
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)