Skip to main content

Understanding JavaScript Closures (Without the Confusion)

5 min read

Closures are one of those concepts that sound scarier than they are. Let’s demystify them with examples you’ll actually use.

The One-Sentence Explanation

A closure is when a function remembers variables from its outer scope, even after that outer scope has finished executing.

That’s it. Everything else is details.

Example 1: The Classic Counter

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

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

What’s happening:

  1. createCounter creates a variable count
  2. Returns a function that uses count
  3. Even after createCounter finishes, the inner function still has access to count
  4. That’s a closure!

Example 2: Private Variables

Closures let you create truly private data:

function createUser(name) {
  let password = 'secret123';  // Private!
  
  return {
    getName() {
      return name;
    },
    checkPassword(attempt) {
      return attempt === password;
    },
    // No way to directly access password
  };
}

const user = createUser('Max');
console.log(user.getName());           // 'Max'
console.log(user.checkPassword('123')); // false
console.log(user.password);             // undefined - can't access!

No one can see password from outside. It’s truly private.

Example 3: Event Handlers

This is where closures shine:

function setupButtons() {
  const buttons = ['Login', 'Logout', 'Settings'];
  
  buttons.forEach((label, index) => {
    const button = document.createElement('button');
    button.textContent = label;
    
    // This function closes over 'label' and 'index'
    button.addEventListener('click', () => {
      console.log(`Button ${label} (${index}) clicked`);
    });
    
    document.body.appendChild(button);
  });
}

Each click handler remembers its specific label and index.

The Common Mistake: Loop Closures

This doesn’t work as expected:

//  Wrong - all click handlers log 3
for (var i = 0; i < 3; i++) {
  const button = document.createElement('button');
  button.textContent = i;
  
  button.addEventListener('click', () => {
    console.log(i);  // Always logs 3!
  });
}

Why? All handlers share the same i, which ends up as 3.

Fix 1: Use let

//  Right - let creates new scope each iteration
for (let i = 0; i < 3; i++) {
  const button = document.createElement('button');
  button.textContent = i;
  
  button.addEventListener('click', () => {
    console.log(i);  // Logs correct value!
  });
}

Fix 2: IIFE (Old school)

for (var i = 0; i < 3; i++) {
  (function(index) {
    const button = document.createElement('button');
    button.textContent = index;
    
    button.addEventListener('click', () => {
      console.log(index);
    });
  })(i);  // Pass i as argument
}

But really, just use let. It’s 2025.

Example 4: Module Pattern

Create modules with private state:

const ShoppingCart = (function() {
  // Private state
  let items = [];
  
  // Private helper
  function calculateTotal() {
    return items.reduce((sum, item) => sum + item.price, 0);
  }
  
  // Public API
  return {
    add(item) {
      items.push(item);
    },
    
    remove(itemId) {
      items = items.filter(item => item.id !== itemId);
    },
    
    getTotal() {
      return calculateTotal();
    },
    
    getItems() {
      return [...items];  // Return copy, not reference
    }
  };
})();

ShoppingCart.add({ id: 1, price: 10 });
console.log(ShoppingCart.getTotal());  // 10
console.log(ShoppingCart.items);        // undefined - private!

Clean API, no global pollution.

Example 5: Function Factory

Create specialized functions:

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

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

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

Each function remembers its factor.

Example 6: Partial Application

Pre-fill function arguments:

function greet(greeting, name) {
  return `${greeting}, ${name}!`;
}

function partial(fn, ...fixedArgs) {
  return function(...remainingArgs) {
    return fn(...fixedArgs, ...remainingArgs);
  };
}

const sayHello = partial(greet, 'Hello');
const sayHi = partial(greet, 'Hi');

console.log(sayHello('Max'));    // 'Hello, Max!'
console.log(sayHi('Alex'));      // 'Hi, Alex!'

React Example: Custom Hooks

Closures are everywhere in React:

function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  
  // These functions close over 'count' and 'setCount'
  const increment = useCallback(() => {
    setCount(c => c + 1);
  }, []);
  
  const decrement = useCallback(() => {
    setCount(c => c - 1);
  }, []);
  
  const reset = useCallback(() => {
    setCount(initialValue);
  }, [initialValue]);
  
  return { count, increment, decrement, reset };
}

Memory Considerations

Closures keep variables alive:

function createHugeArray() {
  const huge = new Array(1000000).fill('data');
  
  return function() {
    return huge.length;  // Whole array stays in memory!
  };
}

Solution: Only close over what you need:

function createHugeArray() {
  const huge = new Array(1000000).fill('data');
  const length = huge.length;  // Cache what you need
  
  return function() {
    return length;  // Only closes over 'length'
  };
  // 'huge' can be garbage collected
}

When to Use Closures

Good use cases:

  • Private variables
  • Event handlers
  • Callbacks
  • Factory functions
  • Partial application
  • Module pattern

When not to use:

  • When you don’t need private state
  • When simpler alternatives exist
  • When memory is tight (mobile)

The Mental Model

Think of closures as a backpack:

function outer() {
  const item = 'lunch';  // Put item in backpack
  
  function inner() {
    console.log(item);   // Still have backpack
  }
  
  return inner;
}

const fn = outer();  // Take backpack with you
fn();  // Open backpack, use item

The inner function carries its environment (backpack) wherever it goes.

Debugging Closures

Chrome DevTools shows closure scope:

function createCounter() {
  let count = 0;
  
  return function() {
    debugger;  // Pause here
    count++;
    return count;
  };
}

const counter = createCounter();
counter();  // Check "Closure" in DevTools

You’ll see count in the Closure scope!

Common Interview Questions

Q: What’s a closure? A: A function that has access to variables from its outer scope, even after that scope has executed.

Q: Why are closures useful? A: Data privacy, event handlers, callbacks, factory functions.

Q: What’s the difference between closure and scope? A: Scope is rules for variable access. Closure is when a function remembers its scope.

Conclusion

Closures aren’t magic. They’re just:

  1. Functions can access outer variables
  2. Functions can be returned/passed around
  3. Functions remember their environment

That’s it. Everything else builds on these basics.

Practice by creating:

  • A counter (private state)
  • Event handlers (remember data)
  • A module (private functions)

Once you’ve built these, closures will click.

Now go close over something! 🎯