DEV Community

Tihomir Ivanov
Tihomir Ivanov

Posted on

Closures & Lexical Scope: How JavaScript Functions "Remember" Where They Were Born

Ask a junior developer to explain closures, and you'll likely get a nervous laugh. Ask a senior developer, and you might get a technically correct but confusing answer. Yet closures are everywhere in JavaScript — they're the invisible force behind React Hooks, event handlers, and countless design patterns.

The truth? Closures are simpler than you think, once you understand lexical scope.

The Golden Rule

A closure is a function that "remembers" variables from its outer (lexical) scope, even after that outer function has finished executing.

In other words: Functions carry their birthplace with them.

Let's build this understanding from the ground up.


Part 1: Understanding Lexical Scope First

Before closures, you need to understand lexical scope (also called static scope).

What is Lexical Scope?

Lexical scope means that variable accessibility is determined by where functions are defined in the code, not where they are called.

const global = 'I am global';

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

  function inner() {
    const innerVar = 'I am inner';
    console.log(global);    // Accessible
    console.log(outerVar);  // Accessible
    console.log(innerVar);  // Accessible
  }

  inner();
}

outer();
Enter fullscreen mode Exit fullscreen mode

Output:

I am global
I am outer
I am inner
Enter fullscreen mode Exit fullscreen mode

Key Point: The inner() function can access:

  1. Its own variables (innerVar)
  2. Variables from outer() (outerVar)
  3. Global variables (global)

This chain is called the scope chain. JavaScript searches for variables starting from the innermost scope and working outward.


Lexical Scope is Determined at "Write Time," Not "Run Time"

const name = 'Outer';

function showName() {
  console.log(name); // Looks for 'name' where showName was DEFINED
}

function wrapper() {
  const name = 'Inner';
  showName(); // Called here, but uses outer 'name'
}

wrapper();
Enter fullscreen mode Exit fullscreen mode

Output:

Outer
Enter fullscreen mode Exit fullscreen mode

Why? showName() was defined in the global scope, so it looks for name in the global scope — even though it's called from inside wrapper().


Part 2: What is a Closure?

A closure happens when a function retains access to its lexical scope even after the outer function has returned.

Classic Closure Example

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

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

const counter = createCounter();

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

What just happened?

  1. createCounter() ran and finished executing
  2. Normally, count would be garbage-collected
  3. But increment() still has a reference to count — this is a closure
  4. Every time we call counter(), it accesses the same count variable

Key Insight: The increment function "closed over" the variable count.


Why This Matters: Data Privacy

In JavaScript, there are no true private variables (until ES2022's # syntax). Closures are the classic workaround:

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

  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 = createBankAccount(100);

console.log(account.getBalance()); // 100
account.deposit(50);               // 150
account.withdraw(30);              // 120

// No way to directly access 'balance' from outside!
console.log(account.balance); // undefined
Enter fullscreen mode Exit fullscreen mode

Result: The balance variable is encapsulated — only the returned methods can access it.


Part 3: Common Closure Patterns

Pattern 1: Function Factory

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
Enter fullscreen mode Exit fullscreen mode

Each function remembers its own multiplier value.


Pattern 2: Event Handlers with Closures

function setupButtons() {
  for (let i = 1; i <= 3; i++) {
    const button = document.createElement('button');
    button.textContent = `Button ${i}`;

    button.addEventListener('click', function() {
      console.log(`Button ${i} clicked`); // Closure over 'i'
    });

    document.body.appendChild(button);
  }
}

setupButtons();
Enter fullscreen mode Exit fullscreen mode

Result: Clicking each button logs the correct number (1, 2, or 3).

Why it works: let creates a new binding for each iteration, and the click handler closes over that specific i.


⚠️ Common Mistake: Using var Instead of let

function setupButtonsWrong() {
  for (var i = 1; i <= 3; i++) { // Using 'var'
    const button = document.createElement('button');
    button.textContent = `Button ${i}`;

    button.addEventListener('click', function() {
      console.log(`Button ${i} clicked`);
    });

    document.body.appendChild(button);
  }
}

setupButtonsWrong();
Enter fullscreen mode Exit fullscreen mode

Result: All buttons log Button 4 clicked (the final value of i after the loop).

Why? var is function-scoped, not block-scoped. All click handlers share the same i variable.

Fix: Use let (block-scoped) or create a closure manually:

for (var i = 1; i <= 3; i++) {
  (function(j) { // IIFE creates new scope
    button.addEventListener('click', function() {
      console.log(`Button ${j} clicked`);
    });
  })(i);
}
Enter fullscreen mode Exit fullscreen mode

Part 4: Closures in React

React Hooks are built on closures. Understanding this is critical for avoiding bugs.

1. useState and Closures

import { useState } from 'react';

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

  const handleClick = () => {
    setCount(count + 1); // 'count' is captured by closure
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

How it works:

  • Each render creates a new handleClick function
  • That function closes over the current value of count
  • When you click the button, it uses the count from that render

2. The Stale Closure Problem

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

  useEffect(() => {
    const id = setInterval(() => {
      console.log(`Count: ${count}`); // Always logs 0!
      setCount(count + 1);             // Always sets to 1!
    }, 1000);

    return () => clearInterval(id);
  }, []); // Empty deps = only runs once

  return <div>{count}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Problem: The setInterval callback closes over the initial count value (0) because the effect only runs once.

Solution 1: Use functional updates

setCount(prevCount => prevCount + 1); // Uses current state
Enter fullscreen mode Exit fullscreen mode

Solution 2: Add count to dependencies

useEffect(() => {
  const id = setInterval(() => {
    console.log(`Count: ${count}`); // Updates every time count changes
  }, 1000);

  return () => clearInterval(id);
}, [count]); // Re-run effect when count changes
Enter fullscreen mode Exit fullscreen mode

Trade-off: Solution 2 restarts the interval every second (might not be desired). Solution 1 is usually better for this case.


3. Event Handlers and Closures

function UserList({ users }) {
  const handleClick = (userId) => {
    console.log(`Clicked user: ${userId}`);
  };

  return (
    <ul>
      {users.map(user => (
        <li key={user.id} onClick={() => handleClick(user.id)}>
          {user.name}
        </li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

What's happening:

  • Each onClick handler is a new function (created on every render)
  • Each closes over the specific user.id from that iteration

Performance note: If UserList re-renders frequently, you might memoize the handler:

const handleClick = useCallback((userId) => {
  console.log(`Clicked user: ${userId}`);
}, []); // Doesn't depend on any props/state
Enter fullscreen mode Exit fullscreen mode

4. Custom Hooks and Closures

function useInterval(callback, delay) {
  const savedCallback = useRef();

  // Remember the latest callback
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  // Set up the interval
  useEffect(() => {
    function tick() {
      savedCallback.current(); // Always calls the latest callback
    }

    if (delay !== null) {
      const id = setInterval(tick, delay);
      return () => clearInterval(id);
    }
  }, [delay]);
}
Enter fullscreen mode Exit fullscreen mode

Why this pattern?

  • The setInterval callback closes over savedCallback.current
  • We update savedCallback.current every render
  • This ensures the interval always calls the latest callback, avoiding stale closures

Usage:

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

  useInterval(() => {
    setCount(count + 1); // 'count' is always current!
  }, 1000);

  return <div>{count}</div>;
}
Enter fullscreen mode Exit fullscreen mode

5. Closures in Context API

const ThemeContext = React.createContext();

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  const toggleTheme = () => { // Closure over 'theme' and 'setTheme'
    setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
  };

  const value = { theme, toggleTheme }; // New object every render

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Optimization: Create the value object with useMemo to prevent unnecessary re-renders:

const value = useMemo(() => ({ theme, toggleTheme }), [theme]);
Enter fullscreen mode Exit fullscreen mode

Part 5: Advanced Closure Concepts

Memory Leaks with Closures

Closures can prevent garbage collection if you're not careful:

function createHugeArray() {
  const hugeArray = new Array(1000000).fill('data');

  return function() {
    console.log(hugeArray[0]); // Closure keeps entire array in memory!
  };
}

const fn = createHugeArray(); // 'hugeArray' can't be garbage-collected
Enter fullscreen mode Exit fullscreen mode

Fix: Only close over what you need:

function createHugeArray() {
  const hugeArray = new Array(1000000).fill('data');
  const firstElement = hugeArray[0]; // Extract what you need

  return function() {
    console.log(firstElement); // Only keeps 'firstElement', not entire array
  };
}
Enter fullscreen mode Exit fullscreen mode

Closures and this Keyword

const obj = {
  name: 'Object',
  regularFunction: function() {
    setTimeout(function() {
      console.log(this.name); // 'this' is undefined (or global object)
    }, 1000);
  },
  arrowFunction: function() {
    setTimeout(() => {
      console.log(this.name); // 'this' is lexically bound to obj
    }, 1000);
  }
};

obj.arrowFunction(); // Logs 'Object'
Enter fullscreen mode Exit fullscreen mode

Why? Arrow functions don't have their own this — they close over the this value from their outer scope.


Quick Reference Cheat Sheet

Concept Explanation Example Use Case
Lexical Scope Variable access determined by where functions are defined Understanding scope chains
Closure Function remembers its outer scope after outer function returns Data privacy, callbacks
Stale Closure Closure captures old variable values Common bug in useEffect
Functional Updates setState(prev => ...) to avoid stale closures Counters, intervals
useCallback Memoizes functions to prevent new closures every render Performance optimization
useRef Mutable container that doesn't cause re-renders Storing latest callback

Key Takeaways

Lexical scope is determined at write time, not run time
Closures allow functions to access outer variables even after the outer function has returned
Use let in loops, not var, to create proper closures
React Hooks rely on closuresuseState, useEffect, and custom hooks all use them
Stale closures are a common bug — fix with functional updates or proper dependencies
Closures can cause memory leaks if you close over large data structures unnecessarily
Arrow functions lexically bind this by closing over the outer this value


Interview Tip

When asked about closures, explain them in three steps:

  1. "A closure is when a function remembers variables from its outer scope"
  2. Give a simple example (like a counter)
  3. Explain a practical use case (React Hooks, data privacy, or event handlers)

Then mention: "In React, understanding closures is critical because Hooks like useState and useEffect create closures over props and state. If you don't handle dependencies correctly, you can get stale closures."

Now go forth and never fear the closure question again!

Top comments (0)