DEV Community

Cover image for JavaScript Closures Explained with Examples
Digital Unicon
Digital Unicon

Posted on

JavaScript Closures Explained with Examples

If you've been writing JavaScript for a while, you've definitely used closures – probably without even realising it. And if you've tried to understand them properly, you've likely hit that wall where the explanation starts making sense and then suddenly… doesn't.

Closures are one of those concepts that feel abstract until the moment they click. And once they do, you'll see them everywhere — in React hooks, in event listeners, in module patterns, in every Node.js callback you've ever written.

This article is about making that click happen. No fluff. No dry theory. Just practical examples with real explanations.


First, Let's Talk About Lexical Scope

Before closures make sense, you need to grok lexical scope.

In JavaScript, a function can access variables from its own scope and any outer scope it was defined in. That's it. That's lexical scope.

const greeting = "Hello";

function sayHi() {
  const name = "Alice";

  function innerFunction() {
    // Can access both `greeting` (outer outer) and `name` (outer)
    console.log(`${greeting}, ${name}!`);
  }

  innerFunction();
}

sayHi(); // "Hello, Alice!"
Enter fullscreen mode Exit fullscreen mode

The key word is defined, not called. Where a function is written determines what variables it has access to. This is the foundation closures are built on.


Okay, So What Actually Is a Closure?

Here's a plain-English definition:

A closure is a function that remembers the variables from the scope where it was created, even after that scope has finished executing.

Let's make that concrete:

function makeCounter() {
  let count = 0; // This variable lives in makeCounter's scope

  return function () {
    count++;
    console.log(count);
  };
}

const counter = makeCounter();

counter(); // 1
counter(); // 2
counter(); // 3
Enter fullscreen mode Exit fullscreen mode

makeCounter()runs and finishes. In a world without closures, count '' would be gone. But the inner function *closed over*count it — it holds a live reference to it. That's why incrementing still works.

How Does This Work Internally?

When JavaScript executes makeCounter(), it creates an execution context with a variable count. When that function returns the inner function, the execution context would normally be garbage-collected. But since the inner function still holds a reference to `count it, the engine keeps it alive.

Think of it like this: the returned function carries a backpack containing all the variables it needs from its birth environment. It takes that backpack everywhere it goes.

mermaid
graph TD
A["makeCounter() called"] --> B["Execution context created\n count = 0"]
B --> C["Inner function created\n (closes over count)"]
C --> D["makeCounter() returns\n inner function"]
D --> E["Execution context would normally die"]
E --> F["BUT inner function holds reference to count"]
F --> G["count stays alive in memory\n via closure"]


Practical Examples: Where Closures Shine

1. Private Variables / Data Encapsulation

JavaScript doesn't have `private 'keywords' (well, it does now in classes, but bear with me). Closures let you simulate privacy:

function createBankAccount(initialBalance) {
  let balance = initialBalance; // Private — not accessible from outside

  return {
    deposit(amount) {
      balance += amount;
      console.log(`Deposited ${amount}. Balance: ${balance}`);
    },
    withdraw(amount) {
      if (amount > balance) {
        console.log("Insufficient funds!");
        return;
      }
      balance -= amount;
      console.log(`Withdrew ${amount}. Balance: ${balance}`);
    },
    getBalance() {
      return balance;
    },
  };
}

const account = createBankAccount(100);
account.deposit(50);    // Deposited 50. Balance: 150
account.withdraw(30);   // Withdrew 30. Balance: 120
console.log(account.balance); // undefined — balance is private!
Enter fullscreen mode Exit fullscreen mode

The balancevariable is completely inaccessible from the outside. The only way to interact with it is through the methods you expose. This is the module pattern in its purest form.


2. Event Listeners

Closures power practically every event listener you'll ever write:

function setupButton(buttonId, message) {
  const button = document.getElementById(buttonId);

  button.addEventListener("click", function () {
    // This function closes over `message`
    alert(message);
  });
}

setupButton("btn1", "You clicked button 1!");
setupButton("btn2", "You clicked button 2!");
Enter fullscreen mode Exit fullscreen mode

Each call to setupButton "creates a *new* closure with its own". The event handler remembers which message belongs to which button, even thoughsetupButton the program has long since finished running.


3. Factory Functions

Need to create multiple similar functions with different configurations? Closures make factories elegant:

function createMultiplier(factor) {
  return function (number) {
    return number * factor;
  };
}

const double = createMultiplier(2);
const triple = createMultiplier(3);
const tenX = createMultiplier(10);

console.log(double(5));  // 10
console.log(triple(5));  // 15
console.log(tenX(5));    // 50
Enter fullscreen mode Exit fullscreen mode

Each function closes over its own``. Clean, reusable, and no global state in sight.


4. Function Currying

Currying transforms a function that takes multiple arguments into a chain of single-argument functions. Closures are what make this possible:

`javascript
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn(...args);
}
return function (...moreArgs) {
return curried(...args, ...moreArgs);
};
};
}

function add(a, b, c) {
return a + b + c;
}

const curriedAdd = curry(add);

console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6
`

Each partial application closes over the accumulated``. The function "remembers" what it's already received and waits for the rest.


5. React Hooks and Closures

If you've used React, you've lived inside closures without knowing it. Every time you write useState " oruseEffect ', closures are doing the heavy lifting.

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      // This callback closes over `count`
      console.log(`Current count: ${count}`);
    }, 1000);

    return () => clearInterval(interval);
  }, [count]); // count in deps array forces effect to re-run

  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Enter fullscreen mode Exit fullscreen mode

The famous stale closure bug in React happens when your effect captures an old value of a state variable. That's why the dependency array matters — it controls when the closure "refreshes" its captured values. Understanding closures is the key to understanding why React hooks behave the way they do.


Common Mistakes Developers Make

The Classic Loop Bug

This one has bitten everyone at least once:

// ❌ Broken — all listeners alert "5"
for (var i = 0; i < 5; i++) {
  document.getElementById(`btn${i}`).addEventListener("click", function () {
    alert(i); // Closes over `i`, not a copy of it
  });
}

// ✅ Fixed with let (block scope creates a new binding each iteration)
for (let i = 0; i < 5; i++) {
  document.getElementById(`btn${i}`).addEventListener("click", function () {
    alert(i); // Each iteration has its own `i`
  });
}

// ✅ Also fixed with an IIFE (old school)
for (var i = 0; i < 5; i++) {
  (function (j) {
    document.getElementById(`btn${j}`).addEventListener("click", function () {
      alert(j);
    });
  })(i);
}
Enter fullscreen mode Exit fullscreen mode

The varversion creates one ivariable. All five closures close over the same i time. By the time any button is clicked, the loop has finished andiis "". Usinglet` creates a fresh binding per iteration — problem solved.

Accidentally Sharing State

javascript
// ❌ Both counters share the same
count`
let count = 0;

const counter1 = () => ++count;
const counter2 = () => ++count;

// ✅ Each counter gets its own closed-over count
function makeCounter() {
let count = 0;
return () => ++count;
}

const counter1 = makeCounter();
const counter2 = makeCounter();
`


Performance Considerations and Memory

Closures are powerful, but they come with a cost you should be aware of.

Memory retention: Variables captured by a closure can't be garbage-collected as long as the closure lives. In most cases, this is fine. But watch out for:

  • Long-lived closures holding large objects — if your closure captures a huge array or DOM element and the closure lives for a long time (say, in a global event listener), that memory is stuck.
  • Closures inside loops creating many function objects — each iteration creates a new function object with its own scope. For hot paths, this can pressure the garbage collector.

javascript
// ⚠️ Be careful —
largeDatacan't be GC'd as long asprocess` is alive
function createProcessor() {
const largeData = new Array(1_000_000).fill("data");

return function process(index) {
return largeData[index];
};
}

// ✅ If you only need a small subset, extract it
function createProcessor() {
const largeData = new Array(1_000_000).fill("data");
const relevantSlice = largeData.slice(0, 100); // Only keep what you need

return function process(index) {
return relevantSlice[index];
};
}
`

In practice, for most applications, closure performance is a non-issue. Modern JS engines (V8, SpiderMonkey) are heavily optimised for them. Just be mindful in scenarios with many closures created rapidly, or closures holding onto large data for long periods.


Key Takeaways

  • A closure is a function that remembers variables from the scope where it was defined, not where it's called.
  • Lexical scope determines what a function can access. Closures make that access persistent.
  • Every function in JavaScript is technically a closure — most of the time, we just don't think about it.
  • Closures are the foundation of private variables, factory functions, currying, event handlers, and React hooks.
  • The classic var "in a loop" bug is a closure-scoping problem. let` and IIFEs both fix it.
  • Be mindful of long-lived closures holding large data — they prevent garbage collection.

Conclusion: The Mental Model That Sticks

Here's the mental model to keep with you: a function carries a backpack.

When a function is created, it packs into that backpack every variable it might ever need from its surrounding environment. It doesn't copy the values — it holds live references. Wherever the function travels, the backpack goes with it.

That's a closure.

Once you see it this way, everything clicks: the counter that increments, the private balance that can't be touched, the event listener that remembers which button it belongs to, and the React hook that captured stale state.

They're all just functions with backpacks, carrying the past into the future.

Top comments (0)