What Are Closures in JavaScript? Explained for AI-Assisted Developers

Closures show up in almost every piece of JavaScript AI generates. Once you see what they are, you'll spot them everywhere.

TL;DR

A closure is a function that remembers variables from its outer scope even after the outer function has finished running. Every function in JavaScript forms a closure. AI generates closures constantly — in event handlers, React hooks, factory functions, and timers. Understanding closures helps you debug stale values, memory leaks, and unexpected behavior in AI-generated code.

Why AI Coders Need to Know This

You ask Cursor to add a click counter button. The code works. Then you ask it to add three independent counters — and all three share the same count. Or you ask Claude to build a timer, and it reads a value that never updates. These are closure bugs.

Closures are one of those concepts that seem abstract until you hit the bug. Once you understand what a closure is, you can:

  • Explain to the AI exactly what's wrong ("the callback has a stale closure over count")
  • Read generated code and understand why it's structured the way it is
  • Fix the "stale closure" bug that trips up almost every React developer
  • Write better prompts that produce non-buggy closures the first time

Real Scenario

Your prompt to the AI

"Create a button that shows how many times it's been clicked. I want a factory function so I can create multiple independent counters."

This is a classic closure use case. The AI needs to create a function that returns another function — and that returned function needs to remember a private counter that no outside code can touch.

What AI Generated

// Factory function — creates an independent counter
function makeCounter(initialValue = 0) {
  // This variable is "closed over" by the returned function
  let count = initialValue;

  return function increment() {
    count += 1;
    console.log(`Count is now: ${count}`);
    return count;
  };
}

// Create two completely independent counters
const counterA = makeCounter();
const counterB = makeCounter(10); // starts at 10

counterA(); // Count is now: 1
counterA(); // Count is now: 2
counterB(); // Count is now: 11
counterA(); // Count is now: 3  (counterA is unaffected by counterB)
counterB(); // Count is now: 12

Understanding Each Part

The outer function: makeCounter

makeCounter is a factory function — it creates and returns something. When it runs, it creates a local variable count. Normally, local variables disappear when their function returns. But there's a catch...

The inner function: increment

The increment function is defined inside makeCounter. It uses count — a variable from its outer scope. JavaScript says: "since this function references count, I'll keep count alive even after makeCounter returns." That's the closure — increment closes over count.

Why counterA and counterB are independent

Each call to makeCounter() creates a brand new execution context with its own count variable. counterA closes over one count; counterB closes over a different count. They never interfere. This is one of closures' most powerful features: private, isolated state.

The mental model

Think of a closure as a function with a backpack. The backpack contains all the variables from the outer scope when the function was created. Wherever the function goes — passed to another function, returned, stored in an array — its backpack comes with it.

Closures in React hooks

React's useState and useEffect are built on closures. When you write:

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

useEffect(() => {
  const timer = setInterval(() => {
    console.log(count); // This closes over the 'count' value from this render
  }, 1000);
  return () => clearInterval(timer);
}, []); // Empty deps: the closure captures count = 0 and never updates

The callback inside setInterval closes over the count value from the first render (0). Even if count changes later, the callback still reads 0. This is the infamous stale closure bug.

What AI Gets Wrong

1. Stale closures in React useEffect

AI frequently generates useEffect with an empty dependency array when state values are used inside. The closure captures the initial value and never updates. Fix: add the state variable to the dependency array, or use useRef for values that need to stay current inside async callbacks.

2. Loop closures with var

Classic trap: AI may write a loop using var and attach event listeners inside. Because var doesn't create a new scope per iteration, all handlers share the same variable and capture the loop's final value. Fix: use let (which does create a new scope per iteration) or wrap in an IIFE.

// Bug: all buttons log 5
for (var i = 0; i < 5; i++) {
  button.addEventListener('click', () => console.log(i));
}

// Fix: use let
for (let i = 0; i < 5; i++) {
  button.addEventListener('click', () => console.log(i));
}

3. Memory leaks from lingering closures

Event listeners, timers, and subscriptions create closures that keep their outer scope alive. AI often forgets cleanup functions in React's useEffect. Always return a cleanup function from useEffect when creating timers or subscriptions.

4. Overly complex closure chains

AI can generate deeply nested functions where it's hard to trace which variable is closed over by which function. If generated code is hard to read, ask the AI to "flatten the closure chain" or "refactor to use a class or module instead."

How to Debug with AI

Diagnosing a stale closure

Symptom: A value inside a callback always shows the initial value, not the current one.

Debugging prompt: "My useEffect callback reads count as 0 even when it's changed. I think this is a stale closure. The effect has an empty dependency array. How do I fix this so it always reads the current count?"

Diagnosing loop closure bugs

Symptom: All event handlers show the same index number.

Debugging prompt: "All my buttons log '5' instead of their own index. The loop uses var. Is this a closure bug, and should I change var to let to fix it?"

Tool-specific tips

  • Cursor: Ask it to add console.log statements at the point of closure capture to verify which value is being closed over.
  • Claude Code: Paste the buggy code and ask: "Identify which variables are closed over by each function and whether any of them might be stale."
  • Windsurf: Ask it to refactor loop closures to use let and to add cleanup returns to all useEffect hooks.

What to Learn Next

Closures build on scope and connect directly to how React state works:

Key Insight

Closures are not a trick or an advanced feature — they're just what happens when a function uses a variable from outside itself. Once you see them that way, they stop being scary and start being useful.

FAQ

A closure is a function that remembers variables from its outer scope even after that outer function has returned. In JavaScript, every function creates a closure over the variables available when it was defined.

Closures exist because JavaScript functions are first-class values — they can be passed around, stored in variables, and returned from other functions. When a function is passed somewhere else, it needs to carry its context with it. That carried context is the closure.

A counter factory is a classic example: a function that creates and returns another function, where the inner function remembers a count variable from the outer function. Every call to makeCounter() creates a new, isolated counter with its own private count.

Yes. Closures keep their outer scope alive in memory as long as the closure exists. If you create closures inside event listeners and never remove those listeners, the closed-over variables stay in memory indefinitely. Always clean up event listeners in components that unmount.

Yes, heavily. The callback you pass to useEffect forms a closure over the current render's state and props. The stale closure problem in React — where a callback reads an old value of state — is a direct consequence of this. Fixing it requires adding variables to the dependency array or using useRef.