javascript Coursejavascriptclosuresscopelexical-scopingfunctionsfundamentalsbeginnertutorial

Understanding JavaScript Closures: The Power and The Pitfalls

17 min read

Understanding JavaScript Closures: The Power and The Pitfalls

I was building a dynamic list of buttons, each with its own click handler. I wrote what I thought was perfectly reasonable code—a loop that created buttons and attached event listeners to each one. When I tested it, every button did the same thing: they all triggered the action for the last item in the list. I spent hours debugging, checking my logic, re-reading the code, and finally realized the problem wasn't my logic. It was my fundamental misunderstanding of how closures work in JavaScript.

// My broken code
const buttons = ['Button 1', 'Button 2', 'Button 3'];
for (var i = 0; i < buttons.length; i++) {
  const button = document.createElement('button');
  button.textContent = buttons[i];
  button.addEventListener('click', function () {
    console.log(`Clicked: ${buttons[i]}`); // Always logs "Button 3"!
  });
  document.body.appendChild(button);
}

That's when I decided to dive deep into closures—the powerful JavaScript feature that lets functions "remember" their environment. Understanding closures isn't just academic knowledge; it's essential for writing modern JavaScript, using React hooks effectively, creating private variables, and avoiding the exact bug I just described.

In this post, we're going to explore JavaScript closures from the ground up. We'll start with what closures are (hint: it's more than just "functions inside functions"), why they exist, how they work under the hood, and most importantly, what I learned the hard way about using them safely and effectively.

Intended audience: JavaScript developers who want to understand closures deeply—from beginners who've used them without realizing it, to intermediate developers who want to understand the "why" behind closures and avoid common pitfalls.

Table of Contents


What Are Closures? (More Than You Think)

When I first heard about closures, I thought: "Oh, it's just functions inside functions." But that's not quite right. A closure is created when an inner function has access to variables from an outer (enclosing) scope, even after the outer function has finished executing.

Here's the simplest closure example:

function outer() {
  const message = 'Hello from outer!';

  function inner() {
    console.log(message); // Accesses message from outer scope
  }

  return inner;
}

const innerFunction = outer();
innerFunction(); // "Hello from outer!"

Wait—this is interesting. The outer() function has finished executing. Its execution context should be gone, right? But innerFunction() can still access message. That's the closure in action: the inner function "closes over" the variables it needs from the outer scope, preserving them even after the outer function returns.

The Key Insight

A closure is formed when:

  1. An inner function references variables from an outer scope
  2. The inner function is returned or passed somewhere else
  3. The outer function's execution context is preserved because the inner function still needs it

This is why closures are so powerful—they let functions "remember" their environment. But it's also why they can be confusing and lead to bugs if you don't understand how they work.


Why Closures Exist: The Design Decision

Why would JavaScript designers make functions behave this way? After researching this, I discovered that closures solve several critical problems in JavaScript programming.

Historical Context

JavaScript was designed in 1995 for the browser. It needed a way to:

  • Encapsulate data: Create private variables and functions (JavaScript doesn't have private class members in the traditional sense)
  • Maintain state: Functions needed to "remember" their context even after execution
  • Enable callbacks: Functions passed as callbacks needed access to their original environment
  • Support functional programming patterns: Higher-order functions, partial application, and currying all rely on closures

The Problem Closures Solve

Without closures, every variable would need to be global, leading to:

  • Naming conflicts: Multiple functions using the same variable names
  • Data exposure: No way to create truly private data
  • Lost context: Callbacks would lose access to their original scope
  • Limited expressiveness: Many useful patterns would be impossible

Closures enable JavaScript to have private variables, maintain state in functions, and support powerful patterns like the module pattern—all without explicit language features for these concepts.

Why Would They Design It This Way?

JavaScript uses lexical scoping (also called static scoping). This means scope is determined by where code is written, not where it's called. This design choice enables closures naturally—when a function is defined, it captures its lexical environment (the variables it can see).

This isn't JavaScript being quirky. It's a deliberate design that enables:

  • Data privacy: Private variables through closures
  • State management: Functions that remember their state
  • Callbacks: Functions that maintain their context
  • Functional patterns: Higher-order functions and partial application

The "why" behind closures helps explain the "how"—and why they work the way they do.


How Closures Work: Lexical Scoping in Action

To understand closures, you need to understand lexical scoping. I covered this in detail in my Execution Context post, but here's the key point: scope is determined by where code is written, not where it's called.

Lexical Scoping Review

const globalVar = 'global';

function outer() {
  const outerVar = 'outer';

  function inner() {
    const innerVar = 'inner';
    // Can access: innerVar (own scope), outerVar (outer scope), globalVar (global scope)
    console.log(innerVar, outerVar, globalVar);
  }

  inner();
}

outer();

When inner() tries to access outerVar, JavaScript looks up the scope chain: first in inner()'s scope, then in outer()'s scope, then in the global scope. This chain is established when the function is defined, not when it's called.

Closures Preserve the Scope Chain

Here's where it gets interesting: when you return an inner function, it maintains a reference to its lexical environment (the scope chain). This means the outer function's variables stay alive even after the outer function finishes executing.

function createCounter() {
  let count = 0; // This variable would normally be garbage collected

  return function () {
    count++; // But this closure preserves it!
    return count;
  };
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

Even though createCounter() has finished executing, count is preserved because the returned function (which forms a closure) still references it.

Multiple Closures, Multiple States

Each call to the outer function creates a new execution context, which means each closure has its own independent state:

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

const counter1 = createCounter();
const counter2 = createCounter();

console.log(counter1()); // 1
console.log(counter1()); // 2
console.log(counter2()); // 1 (independent state!)
console.log(counter1()); // 3

counter1 and counter2 have separate count variables. Each closure captures its own instance of the outer function's variables.


Common Use Cases: Where Closures Shine

Once I understood closures, I started seeing them everywhere in JavaScript code. Here are the most common and useful patterns:

1. Data Privacy and Encapsulation

Closures let you create private variables—something JavaScript doesn't have natively (at least not in the traditional class sense):

function createBankAccount(initialBalance) {
  let balance = initialBalance; // Private variable

  return {
    deposit: function (amount) {
      balance += amount;
      return balance;
    },
    withdraw: function (amount) {
      if (amount > balance) {
        return 'Insufficient funds';
      }
      balance -= amount;
      return balance;
    },
    getBalance: function () {
      return balance;
    },
  };
}

const account = createBankAccount(100);
console.log(account.getBalance()); // 100
account.deposit(50);
console.log(account.getBalance()); // 150
// account.balance; // undefined - can't access directly!

The balance variable is private—it can't be accessed from outside the returned object. Only the methods (which form closures over balance) can access and modify it.

2. Function Factories

Closures enable function factories—functions that create and return other functions:

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

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

console.log(double(5)); // 10
console.log(triple(5)); // 15

Each returned function "remembers" its multiplier value, even though createMultiplier has finished executing.

3. Event Handlers and Callbacks

Event handlers often need access to data from their surrounding context:

function setupButton(name) {
  const button = document.createElement('button');
  button.textContent = name;

  button.addEventListener('click', function () {
    // Closure captures 'name' from outer scope
    console.log(`Button ${name} was clicked!`);
  });

  return button;
}

const btn1 = setupButton('Submit');
const btn2 = setupButton('Cancel');

Each button's click handler forms a closure over its specific name value.

4. Partial Application and Currying

Closures enable partial application—pre-filling some arguments of a function:

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

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

const add5 = partialAdd(5);
console.log(add5(10, 15)); // 30 (5 + 10 + 15)

The inner function forms a closure over a, "remembering" the first argument.

5. Memoization

Closures can cache function results:

function memoize(fn) {
  const cache = {}; // Private cache

  return function (...args) {
    const key = JSON.stringify(args);
    if (cache[key]) {
      return cache[key];
    }
    const result = fn(...args);
    cache[key] = result;
    return result;
  };
}

const slowFunction = (n) => {
  // Expensive computation
  return n * 2;
};

const fastFunction = memoize(slowFunction);

The cache is private to the memoized function, preserved by the closure.


The Loop Problem: My First Closure Bug

Now let's get to the bug that introduced me to closures the hard way. Here's what I wrote:

// ❌ Broken code
const buttons = ['Button 1', 'Button 2', 'Button 3'];
for (var i = 0; i < buttons.length; i++) {
  const button = document.createElement('button');
  button.textContent = buttons[i];
  button.addEventListener('click', function () {
    console.log(`Clicked: ${buttons[i]}`); // Always logs "Button 3"!
  });
  document.body.appendChild(button);
}

Every button logged "Button 3" when clicked. Why?

The Problem

All the event handler functions form closures over the same i variable. Since var is function-scoped (not block-scoped), there's only one i variable for the entire loop. By the time any button is clicked, the loop has finished, and i is 3 (the final value).

The closures don't capture the value of i at each iteration—they capture a reference to the same i variable, which has the final value when the handlers execute.

Solution 1: Use let Instead of var

The easiest fix is to use let, which is block-scoped:

// ✅ Fixed with let
const buttons = ['Button 1', 'Button 2', 'Button 3'];
for (let i = 0; i < buttons.length; i++) {
  const button = document.createElement('button');
  button.textContent = buttons[i];
  button.addEventListener('click', function () {
    console.log(`Clicked: ${buttons[i]}`); // Works correctly!
  });
  document.body.appendChild(button);
}

With let, each iteration creates a new i variable in a new block scope. Each closure captures its own i value.

Solution 2: IIFE (Immediately Invoked Function Expression)

Before let was available, developers used IIFEs to create a new scope for each iteration:

// ✅ Fixed with IIFE
const buttons = ['Button 1', 'Button 2', 'Button 3'];
for (var i = 0; i < buttons.length; i++) {
  (function (index) {
    const button = document.createElement('button');
    button.textContent = buttons[index];
    button.addEventListener('click', function () {
      console.log(`Clicked: ${buttons[index]}`); // Works correctly!
    });
    document.body.appendChild(button);
  })(i);
}

The IIFE creates a new function scope for each iteration, capturing i as index in its own closure.

Solution 3: Use forEach or map

Modern JavaScript offers cleaner alternatives:

// ✅ Fixed with forEach
const buttons = ['Button 1', 'Button 2', 'Button 3'];
buttons.forEach(function (buttonText, index) {
  const button = document.createElement('button');
  button.textContent = buttonText;
  button.addEventListener('click', function () {
    console.log(`Clicked: ${buttonText}`); // Works correctly!
  });
  document.body.appendChild(button);
});

forEach creates a new function scope for each iteration, so each handler closes over its own buttonText and index.

What I Learned

This bug taught me that closures capture variables by reference, not by value. When multiple closures share the same variable, they all reference the same memory location. This is why var in loops causes problems—there's only one i variable that all closures share.


Memory Leaks and Closures: When to Be Careful

Closures preserve variables, which is usually good. But sometimes, this preservation can lead to memory leaks if you're not careful.

The Memory Leak Pattern

Here's a common mistake I made early on:

// ❌ Potential memory leak
function attachHandler() {
  const largeData = new Array(1000000).fill('data'); // Large array

  document.getElementById('myButton').addEventListener('click', function () {
    // Handler doesn't use largeData, but closure captures it anyway!
    console.log('Button clicked');
  });
}

attachHandler();
// largeData can't be garbage collected because the handler forms a closure over it

Even though the event handler doesn't use largeData, the closure still captures it because it's in the same scope. The largeData array can't be garbage collected as long as the event handler exists.

How to Fix It

If you don't need largeData in the closure, move it outside or null it after use:

// ✅ Fixed: Don't capture what you don't need
function attachHandler() {
  document.getElementById('myButton').addEventListener('click', function () {
    console.log('Button clicked');
  });
  // largeData not in scope, so it's not captured
}

// Or if you need it temporarily:
function attachHandler() {
  const largeData = new Array(1000000).fill('data');
  // Use largeData here if needed
  // ...

  document.getElementById('myButton').addEventListener('click', function () {
    console.log('Button clicked');
  });

  largeData = null; // Help garbage collection (though modern JS engines are smarter)
}

DOM References and Closures

Another common leak: closures that capture DOM elements:

// ❌ Potential leak
function setupHandler() {
  const element = document.getElementById('myElement');
  const data = fetchData(); // Large data

  element.addEventListener('click', function () {
    // Closure captures both element and data
    console.log(element.textContent, data);
  });
}

// Even if you remove the element from DOM, the closure keeps it in memory
document.getElementById('myElement').remove();

The closure keeps references to both the DOM element and the data, preventing garbage collection.

Best Practices to Avoid Leaks

  1. Only capture what you need: Be mindful of what variables are in the closure's scope
  2. Remove event listeners: Always remove event listeners when done (or use event delegation)
  3. Null references: Set large variables to null after use if they're captured unnecessarily
  4. Use weak references: In some cases, WeakMap or WeakSet can help (though this is advanced)

Closures in Modern JavaScript: React Hooks and More

Closures are everywhere in modern JavaScript, especially in React. Understanding closures is essential for using hooks effectively.

React Hooks Use Closures

Every React hook relies on closures:

function Counter() {
  const [count, setCount] = useState(0); // State is preserved by closure

  useEffect(() => {
    // This effect forms a closure over 'count'
    document.title = `Count: ${count}`;
  }, [count]);

  return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}

The component function runs on every render, but useState and useEffect use closures to maintain state and effects between renders.

Custom Hooks Are Closures

Custom hooks are just functions that use other hooks—they're closures in disguise:

function useCounter(initialValue) {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);

  // Returns functions that form closures over count and setCount
  return { count, increment, decrement };
}

The Stale Closure Problem in React

Here's a React closure gotcha I encountered:

// ❌ Stale closure
function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCount(count + 1); // Always uses initial count value (0)!
    }, 1000);

    return () => clearInterval(interval);
  }, []); // Empty dependency array

  return <div>{count}</div>; // Stays at 1
}

The problem: the closure in setInterval captures the initial count value (0). Even though count updates, the closure still references the old value.

Solution: Use the functional form of setState:

// ✅ Fixed: Functional setState
function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCount((prevCount) => prevCount + 1); // Uses current value
    }, 1000);

    return () => clearInterval(interval);
  }, []);

  return <div>{count}</div>; // Works correctly!
}

The functional form receives the current state, avoiding the stale closure problem.


Common Mistakes I Made

Here are the closure mistakes I've made (so you can avoid them):

Mistake 1: Assuming Closures Capture Values

I used to think closures captured the value of variables at the time the function was created. They don't—they capture references.

// ❌ Wrong assumption
let value = 1;
const fn = function () {
  console.log(value);
};
value = 2;
fn(); // Logs 2, not 1!

The closure captures a reference to value, so it sees the updated value.

Mistake 2: Not Understanding Loop Closures

The loop problem I described earlier—this is a very common mistake, especially with var.

Mistake 3: Creating Unnecessary Closures

Sometimes I created closures when I didn't need to:

// ❌ Unnecessary closure
function processItems(items) {
  return items.map(function (item) {
    return item * 2; // Doesn't need to be a closure
  });
}

// ✅ Simpler
function processItems(items) {
  return items.map((item) => item * 2);
}

If you're not using variables from the outer scope, you don't need a closure.

Mistake 4: Stale Closures in Async Code

// ❌ Stale closure in async
function fetchData(id) {
  const data = { id };

  setTimeout(function () {
    console.log(data.id); // Might be stale if id changed
  }, 1000);
}

If id changes before the timeout fires, the closure might capture a stale value.


Best Practices: Using Closures Effectively

After making all these mistakes, here's what I learned about using closures effectively:

1. Understand What You're Capturing

Be aware of what variables are in the closure's scope. Only capture what you need.

2. Use let and const Instead of var

Block-scoped variables (let/const) make closures more predictable, especially in loops.

3. Keep Closure Scopes Small

Don't put large data structures in closure scope unless necessary. This helps prevent memory leaks.

4. Document Closure Behavior

If a closure captures variables in a non-obvious way, add a comment explaining it.

5. Test Closure Behavior

When using closures, test that they capture the values you expect, especially in loops or async code.

6. Use Functional setState in React

When using React hooks, prefer functional setState to avoid stale closures.

7. Clean Up Event Listeners

Always remove event listeners when components unmount or when they're no longer needed.


Key Takeaways

  1. Closures are created when inner functions access outer scope variables, even after the outer function returns.

  2. Closures preserve the lexical environment—they "remember" variables from their enclosing scope.

  3. Closures capture variables by reference, not by value. Multiple closures can share the same variable.

  4. Closures enable powerful patterns: data privacy, function factories, callbacks, memoization, and more.

  5. The loop closure problem happens when multiple closures share the same variable (often with var). Use let or create new scopes to fix it.

  6. Closures can cause memory leaks if they capture large objects or DOM elements unnecessarily. Be mindful of what's in the closure's scope.

  7. React hooks rely on closures to maintain state and effects. Understanding closures is essential for React development.

  8. Stale closures happen when closures capture old values. Use functional setState in React to avoid this.

Closures are one of JavaScript's most powerful features, but they can be confusing. Understanding how they work—and why they work the way they do—transforms them from a source of bugs into a powerful tool.

The next time you see a function accessing a variable from an outer scope, or you're debugging why a loop isn't working as expected, think about closures. Ask yourself: "What variables is this function capturing? Are they the values I expect? Is this closure preserving something I don't need?"

These questions will guide you to writing better, more predictable JavaScript code.


Test Your Understanding

🧩 Initializing quiz...
Quiz ID: understanding-javascript-closures

Happy coding! 🚀

Written by Sandeep Reddy Alalla

Share your thoughts and feedback!