DEV Community

Cover image for JavaScript Closures: The Complete Guide (with Cheat Sheet)
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

JavaScript Closures: The Complete Guide (with Cheat Sheet)

Open five browser tabs, click a button in each, and every single one logs 5. Not 1, 2, 3, 4, 5 — five identical 5s, as if the loop that created the buttons never ran at all. This is one of the oldest, most reliable JavaScript bugs there is, and it isn't a bug in JavaScript — it's a closure doing exactly what it's supposed to do, with a variable you didn't realize you were sharing.

What you'll learn

By the end of this guide you'll be able to:

  • Explain what a closure actually is — a function bundled with a live reference to its scope, not a copy of it
  • Use closures to build private state without a class
  • Write a memoization cache and a "run once" guard using nothing but closures
  • Diagnose the classic loop-variable-capture bug and fix it three different ways
  • Recognize the memory-retention gotchas closures introduce, including the "stale closure" bug in React hooks

Who this is for: you're comfortable declaring functions and using var/let, and you've either been bitten by a callback that saw the "wrong" value or you've used useState in React and wondered why an old value showed up inside a useEffect.

Contents

Why closures matter — the loop that logs 5 five times

Here's the naive way to wire up five buttons, each meant to log its own position:

// the wrong way — every button logs the same number
for (var i = 1; i <= 5; i++) {
  const button = document.createElement("button");
  button.textContent = `Button ${i}`;
  button.addEventListener("click", function () {
    console.log(`You clicked button ${i}`); // always logs 5
  });
  document.body.appendChild(button);
}
Enter fullscreen mode Exit fullscreen mode

Click any of the five buttons and the console prints You clicked button 6. Not 1, not the button's own number — always the same value, one past the last iteration. Nothing crashed, no error was thrown, and yet the code clearly doesn't do what it looks like it does. That gap between "what the code appears to say" and "what it actually does" is the entire reason closures deserve a real mental model instead of a shrug and a workaround.

The fix — spoiler, it's one keyword — comes in Stage 4. But the fix only makes sense once you know what a closure is actually holding onto, so that's where we start.

The mental model: a function with a backpack

The mental model: a closure is a function bundled together with a live reference to the variables that were in scope where the function was defined — not the values those variables held at that moment, and not a copy. Every function you write in JavaScript carries this bundle with it, always; "closure" isn't a special kind of function, it's a name for a function's normal relationship to its surrounding scope.

Picture the function as a hiker who packs a backpack the moment they're created, and the backpack holds references to the variables visible around it — not photographs of their current values. If someone back at camp changes a variable in the backpack after the hiker leaves, the hiker's copy of that reference still points at the same variable, so they see the new value too. The hiker doesn't carry a snapshot from the moment they left; they carry a line back to the original.

function makeGreeter(name) {
  return function greet() {
    console.log(`Hello, ${name}`); // `greet` closes over `name`
  };
}

const greetAda = makeGreeter("Ada");
greetAda(); // "Hello, Ada" — greet still has access to `name`, long after makeGreeter returned
Enter fullscreen mode Exit fullscreen mode

makeGreeter finishes executing and its call frame would normally be discarded. But greet still references name, so the JavaScript engine keeps that specific name alive for as long as greet exists. That's the whole mechanism — every stage below is this one idea, applied to a slightly different problem.

Stage 1: a closure is just a function that remembers

The clearest way to see "reference, not copy" is to close over a variable and then change it after the closure was created:

function makeCounter() {
  let count = 0;
  return function () {
    count += 1;
    return count;
  };
}

const counterA = makeCounter();
const counterB = makeCounter();

counterA(); // 1
counterA(); // 2
counterB(); // 1 — a completely separate `count`, from a separate call to makeCounter
Enter fullscreen mode Exit fullscreen mode

Key concept: each call to makeCounter() creates a brand-new count and a brand-new inner function closing over that count. counterA and counterB don't share state — closures are scoped per invocation of the outer function, not per function definition.

This is also the answer to "why didn't count get garbage-collected when makeCounter returned?" It would have, if nothing still referenced it. Something does: the returned function. The variable's lifetime is now tied to the closure's lifetime, not to the block that declared it.

Stage 2: private state without a class

A plain object exposes its fields to anyone holding a reference to it — there's no way to stop account.balance = 1_000_000 from outside. A closure gives you a place to keep state that literally cannot be reached except through the functions you choose to expose:

function createAccount(initialBalance) {
  let balance = initialBalance; // not returned, not attached to anything public

  return {
    deposit(amount) {
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      if (amount > balance) throw new Error("insufficient funds");
      balance -= amount;
      return balance;
    },
    getBalance() {
      return balance;
    },
  };
}

const account = createAccount(100);
account.deposit(50); // 150
account.balance; // undefined — there is no such property; `balance` only exists inside the closure
Enter fullscreen mode Exit fullscreen mode

There's no #balance private field syntax here, no WeakMap trick, no convention like a leading underscore that other code can ignore. balance simply isn't reachable from outside the three functions that closed over it — it never became a property of the returned object at all. This pattern (sometimes called the module pattern) predates JavaScript's class syntax and its #private fields, and it's still the right tool when you want a handful of functions to share hidden state without the ceremony of a class.

Stage 3: memoization — a cache that lives in a closure

A closure is also just a convenient place to keep a cache between calls, with no global variable and no class:

function memoize(fn) {
  const cache = new Map();
  return function (...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) {
      return cache.get(key); // skip re-computing — the closure remembered
    }
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

function slowSquare(n) {
  for (let i = 0; i < 1e8; i++); // pretend this is expensive
  return n * n;
}

const fastSquare = memoize(slowSquare);
fastSquare(5); // slow the first time — computes and caches
fastSquare(5); // instant — the closure's `cache` already has the answer
Enter fullscreen mode Exit fullscreen mode

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

Key concept: cache lives in exactly one place — the closure created by the single call to memoize(slowSquare) — and every call to fastSquare shares that same cache by reference. If you called memoize(slowSquare) a second time, you'd get a second, independent cache, for the same reason counterA and counterB didn't share state in Stage 1.

Stage 4: fixing the loop bug, three ways

Back to the opening example. The bug is that var i creates exactly one binding for the entire loop — not one per iteration — because var is function-scoped (or global-scoped), not block-scoped. All five click handlers close over that same single i, and by the time anyone clicks a button, the loop has already finished and i is 6.

Fix 1 — use let instead of var. This is the fix that shipped in ES2015 specifically to solve this problem: let creates a fresh binding for i on every iteration, so each closure captures its own copy of the loop variable.

for (let i = 1; i <= 5; i++) {
  button.addEventListener("click", function () {
    console.log(`You clicked button ${i}`); // correct — each i is its own binding
  });
}
Enter fullscreen mode Exit fullscreen mode

Fix 2 — wrap the body in an IIFE to force a new scope per iteration. This is the pre-ES2015 fix, and it's worth knowing because you'll still see it in older code: an immediately-invoked function expression creates a new function scope on every pass, and you pass the current i in as an argument, snapshotting its value at that instant.

for (var i = 1; i <= 5; i++) {
  (function (capturedI) {
    button.addEventListener("click", function () {
      console.log(`You clicked button ${capturedI}`); // correct — capturedI is a fresh parameter each time
    });
  })(i);
}
Enter fullscreen mode Exit fullscreen mode

Fix 3 — pass the value as a parameter to the handler factory. The same idea as Fix 2, expressed as a named helper instead of an inline IIFE — often the most readable option in real code:

function makeHandler(n) {
  return function () {
    console.log(`You clicked button ${n}`); // n is this call's own parameter
  };
}

for (var i = 1; i <= 5; i++) {
  button.addEventListener("click", makeHandler(i));
}
Enter fullscreen mode Exit fullscreen mode

All three fixes do the same thing: give each iteration its own variable to close over, instead of letting every closure share one. let just does it automatically, which is why it's the default choice today.

Edge cases and gotchas

  • Closures capture by reference, not by value. If a closure captures an object or array, mutating that object later — from anywhere, not just inside the closure — is visible to the closure the next time it runs. This is the same mechanism as the loop bug, and it cuts both ways: it's occasionally exactly what you want (Stage 2's balance), and occasionally a bug (Stage 4's i).
  • Closures can retain more than you intend. A closure keeps its entire enclosing scope reachable, not just the variables it uses — practically, engines are good at only keeping what's actually referenced reachable, but a closure that captures a large object (a big array, a DOM subtree) alongside a small value you meant to use will keep that large object alive for as long as the closure exists. Long-lived closures — an event listener that's never removed, a timer that never clears — are the usual place this becomes a real memory leak.
  • this is not part of a closure the way ordinary variables are. A regular function gets its own this, determined by how it's called, regardless of what it closes over. An arrow function has no this of its own and looks it up in its enclosing scope like any other closed-over variable — which is exactly why arrow functions are the usual choice for callbacks that need the outer this.
  • React's "stale closure" bug is this exact mechanism. Every render creates a fresh closure over that render's props and state. An event handler or a useEffect callback with a missing dependency closes over the values from the render it was created in — not the latest ones — which is why it can log or use an outdated piece of state even though the component clearly re-rendered since. The exhaustive-deps ESLint rule exists specifically to catch this.
  • Closures aren't free, but they're rarely the bottleneck you'd guess. Each closure that captures variables holds a reference to its enclosing scope for the engine to manage. This matters if you're creating millions of closures in a hot loop over a huge dataset; it does not matter for ordinary UI event handlers, memoization caches, or module-pattern objects — the memory and cost are negligible at that scale.

Best practices: when (not) to reach for a closure

Reach for a closure when you want state shared by a small, fixed set of functions without exposing it (Stage 2), a cache or "computed once" value that should persist between calls (Stage 3), or a factory that produces several independent instances of the same behavior (Stage 1's makeCounter).

Avoid it when you have many methods that all need the same shared state — a class with private #fields expresses that more clearly and with less nesting than a pile of closures returned from one factory function. Closures and classes solve the same problem; classes read better once you're past three or four methods.

Watch it when the closure lives a long time — a global event listener, a setInterval that never clears, a cache with no eviction. The closure will keep everything it references alive for exactly that long, so audit what it's actually capturing, not just what you meant it to capture.

FAQ

Why does my for-loop callback always log the last value?

Because var creates one binding for the whole loop, and every closure created inside the loop shares that single binding — by the time any callback runs, the loop has finished and the variable holds its final value. See Stage 4 for three fixes.

Is a closure a copy of the variables it uses?

No — it's a live reference to the variable itself, in the scope where it was declared. If that variable changes after the closure was created, the closure sees the new value, because it was never holding a snapshot in the first place.

Do arrow functions create closures differently than regular functions?

They close over ordinary variables the same way. The difference is this: an arrow function has no this of its own, so it looks this up in its enclosing scope like any other closed-over variable, while a regular function's this depends on how it's called.

Can closures cause memory leaks?

Yes, indirectly. A closure keeps its enclosing scope's variables reachable for as long as the closure itself is reachable. A closure attached to an event listener that's never removed, or stored in a cache that never evicts, holds onto whatever it captured for that entire time — that's a real, common source of leaks in long-running pages.

What's the difference between a closure and a callback?

They're different concepts that usually overlap. A callback is a function passed to be called later by something else; a closure is the property that lets that function still see the variables from where it was defined. Almost every callback you write is also a closure — the two ideas describe different aspects of the same function.

Do closures work the same way with async/await?

Yes. An async function or a .then() callback closes over its surrounding scope exactly like a synchronous one — including the loop-variable bug, if you write var inside a loop that awaits or schedules something. let fixes it the same way.

Cheat sheet

Pattern Code Notes
Private state function make(){ let x; return {get(){return x}} } x is unreachable except through the returned methods
Independent instances call the factory again each call creates a new scope; closures don't share across calls
Memoize const cache = new Map() inside the factory cache persists across calls to the returned function
Run once let ran = false; return () => { if (ran) return; ran = true; ... } guards side effects that must happen exactly once
Fix loop capture use let i, not var i gives every iteration its own binding
Pre-ES2015 loop fix wrap in an IIFE, pass i as a parameter manually recreates a fresh binding per iteration
// The whole pattern, copy-paste ready: private state + memoized method + run-once init
function createWidget(id) {
  let ready = false;
  const cache = new Map();

  function init() {
    if (ready) return; // run-once guard
    ready = true;
    console.log(`widget ${id} initialized`);
  }

  function computeExpensive(key) {
    if (cache.has(key)) return cache.get(key); // memoized
    const value = key.toUpperCase(); // stand-in for real work
    cache.set(key, value);
    return value;
  }

  return { init, computeExpensive }; // id, ready, and cache stay private
}

const widget = createWidget("nav-1");
widget.init(); // logs once
widget.init(); // no-op — ready is already true
widget.computeExpensive("hero"); // computes and caches
widget.computeExpensive("hero"); // returns the cached value
Enter fullscreen mode Exit fullscreen mode

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

Key takeaways

  • A closure is a function bundled with a live reference to its enclosing scope — not a snapshot of the values at creation time.
  • Each call to an outer function creates a fresh scope; closures created in separate calls don't share state, but closures created in the same call do.
  • The classic loop bug happens because var creates one binding for the whole loop; let (or a manual IIFE) gives each iteration its own.
  • Closures are how you get private state and memoization without a class — but a long-lived closure keeps everything it captured alive for exactly as long as it exists.
  • React's "stale closure" bug is the same mechanism as the loop bug: a handler closing over a value from the render it was created in, not the latest one.

Back to the five buttons from the top: swap that var for a let, and each one finally logs its own number — not because the bug fixed itself, but because each iteration now gets its own binding for a closure to hold onto. What's the first place in your own code you'd bet a var-in-a-loop closure bug is still hiding? Tell me in the comments.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (0)