Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.