Closures in JavaScript, and the stale-closure bug in React
Tien Nguyen
· 8 min read
A closure is the concept behind the single most common React bug I have ever chased: a useEffect stuck forever on a value from the first render, so a count that should climb freezes at 1. If you have hit that and papered over it, this is the idea you were missing. And in 2026 it matters more, not less, because a stale closure is exactly the kind of subtle bug an AI writes for you and a junior pastes without noticing.
So, plainly: a closure is a function bundled with the variables from the scope where it was born. It remembers those variables even after that scope is gone. Every JavaScript function is technically a closure. You only feel it when an inner function outlives the outer one that made it. I have hit this bug more than any other precisely because it is invisible in a code review: the code reads as correct and still does the wrong thing. And it is pure JavaScript underneath.
What is a closure in JavaScript?
Here is the whole idea in one runnable example. A real utility you have probably written: a generator that hands out unique IDs (for list keys, DOM ids, form fields), keeping its running count private:
function makeIdGenerator(prefix) { let count = 0; // private, and it survives between calls return () => `${prefix}-${(count += 1)}`;}
const nextId = makeIdGenerator('row');nextId(); // "row-1"nextId(); // "row-2"makeIdGenerator has already returned by the time you call nextId(). Its local count should be gone. It is not. The returned arrow function closed over count, so that variable stays alive for exactly as long as the function that uses it does. That is a closure: the inner function kept a live reference to the scope it was born in.
Make a second generator and it gets its own independent count. The closures do not share; each call to makeIdGenerator creates a fresh scope, which is exactly why two ID generators never collide.
How do closures actually work?
The mechanism is one rule: a function remembers where it was defined, not where it is called. When you define a function, JavaScript ties it to its birth scope (its lexical environment). Later, calling it from anywhere still resolves variables in that original scope.

That single rule is what makes real encapsulation possible. Here is private state you genuinely cannot reach from outside:
function createWallet(initial) { let balance = initial; // no code outside can read or write this directly
return { deposit: (amount) => (balance += amount), balance: () => balance, };}
const wallet = createWallet(100);wallet.deposit(50);wallet.balance(); // 150There is no wallet.balance property to tamper with, only a function that closes over balance. This factory function returns closures, and it is how JavaScript did private fields for years before the language had #private syntax. Closures aren’t a special-case trick. A lot of everyday JavaScript holds state exactly this way without you noticing, and it is one of the real-JavaScript fundamentals a good React learning path puts before the framework.
The classic closure bug: var in a loop
Every JavaScript developer hits this one. Make functions in a loop with var, and they all see the same final value:
const withVar = [];for (var i = 0; i < 3; i++) { withVar.push(() => i); // every function closes over the SAME i}withVar.map((f) => f()); // [3, 3, 3]
const withLet = [];for (let j = 0; j < 3; j++) { withLet.push(() => j); // let: a fresh j for each iteration}withLet.map((f) => f()); // [0, 1, 2]With var, there is one i shared by every function, and by the time you call them the loop has finished and i is 3. With let, each iteration gets its own binding, so each closure captures a different number. Same code shape, different result, entirely because of what each closure closed over.
What is a stale closure in React?
This is the version that empties an afternoon into a debugger. In React, an effect is a function, which means it is a closure, which means it captures the state from the render it was created in:
function Chat({ socket }) { const [messages, setMessages] = useState([]);
useEffect(() => { socket.on('message', (msg) => { setMessages([...messages, msg]); // `messages` was captured on the first render: always [] }); return () => socket.off('message'); }, []); // [] deps: this handler closes over the initial empty list, forever
return <p>{messages.length} messages</p>;}The message count never climbs past one. The socket handler closed over messages from the first render, where it was the empty array, and with an empty dependency array the effect never re-subscribes to capture a newer one. So every incoming message computes [] + msg and overwrites the last. It is the same stale-closure trap I showed with a counter in is React easy to learn; once you can see the closure, you spot it in any shape.
The fix is to stop depending on the captured value and ask React for the current one:
setMessages((prev) => [...prev, msg]); // the updater always receives the latest stateEvery stale closure in React reduces to the same three lines:
| The stale closure | |
|---|---|
| Symptom | A value frozen at its first-render state (a list that never grows, a count stuck at 1) |
| Cause | A callback in useEffect with [] deps closed over the old value |
| Fix | Use the functional updater (setState(prev => ...)), or add the value to the deps |
Here is why this matters in 2026 specifically. A stale closure is invisible if you do not understand closures: the code looks correct, it runs, and it is subtly wrong. That is precisely the kind of bug an AI code generator produces and a developer pastes without reading. If you can see the closure, you can fix it in seconds. If you cannot, you are stuck staring at code that “should work.” Understanding closures is a big part of the difference between directing AI and being fooled by it.
When are closures a problem?
Almost never, but there is one honest pitfall. A closure keeps its entire birth scope alive in memory until the closure itself is garbage collected. Usually that is invisible and fine. It only bites when you hold onto many long-lived closures that each capture something large (a big array, a DOM node, a whole component’s state) that you no longer need.
Drop references to the closures you are finished with (clear the interval, remove the listener, let the component unmount), and their captured scope gets collected. You do not have to avoid closures, just stop holding onto the ones you no longer need. A closure is memory that lives exactly as long as the function using it, no longer.
The takeaway
A closure is a function that remembers the variables from where it was defined. That one idea powers private state, the module pattern, and function factories, and it is the reason a useEffect can log a number that is frozen in the past. The same JavaScript fundamentals, like how this binds, decide whether the code an AI hands you is something you can debug or something you can only hope works.
Learn closures once, properly, and a whole category of “why is this value wrong?” bugs turns from mysterious into obvious. In an era where a machine writes a lot of your JavaScript, that is exactly the kind of understanding that keeps you in charge of the code.
Frequently asked questions
What is a closure in JavaScript?
A closure is a function bundled together with the variables from the scope where it was created. The function keeps access to those variables even after that outer scope has finished running. In practice, that means an inner function can read and update variables from the function that made it, long after the outer function returned. Every function you write in JavaScript is a closure; you just only notice it when the inner function outlives the outer one.
How do closures work?
When you define a function, JavaScript attaches it to the scope it was born in (its lexical environment). Calling that function later, from anywhere, still looks variables up in that original scope, not wherever it happens to be called. So the variables from the outer function stay alive as long as the inner function that references them is alive. That is the whole mechanism: functions remember where they were defined, not where they are called.
What is a stale closure in React?
A stale closure is a function that captured an old value and never sees the new one. It is the most common version of the bug in React hooks. A callback inside useEffect with an empty dependency array closes over the state from the first render, so it keeps using that first value forever even as state updates. The fix is to not depend on the captured value: use the functional updater form (setCount(c => c + 1)) or add the value to the dependency array.
When should you not use closures?
Closures are unavoidable and almost always fine, but they keep their entire birth scope alive in memory until the closure itself is garbage collected. So the one real pitfall is holding onto many long-lived closures that each capture large objects (big arrays, DOM nodes, whole component states) you no longer need. That can quietly grow memory. Clear the interval, remove the listener, or let the component unmount, and the captured scope frees itself. You do not need to avoid closures, just stop holding onto ones you no longer need.
Are closures a common JavaScript interview question?
Yes, closures are one of the most asked JavaScript interview topics. The two classics are writing a counter with a private variable, and explaining why a for loop with var logs the same number every time (and how let fixes it). If you can explain both, plus what a stale closure in React is, you have covered what almost every interviewer is checking for.
Why do AI code generators cause stale closure bugs?
AI coding tools are trained on a lot of React, and they reliably produce the stale-closure pattern: a useEffect or callback with an empty dependency array that reads a piece of state or props. That code looks correct and runs, so it passes a quick glance and gets pasted in. But the callback closed over the value from the first render and never sees updates, so it silently uses old data. The reason it slips through is that a stale closure is invisible unless you understand closures: no error, no warning, just a value that is quietly out of date. That is why closures are one of the highest-leverage things to understand in 2026. They are exactly the class of bug a machine writes confidently and a developer accepts without reading. If you can see the closure, you catch it in review. If you cannot, it ships.
Keep reading
Tien Nguyen
· 9 min read
The this keyword in JavaScript, actually explained
How the this keyword in JavaScript really works: one mental model (read the call-site), the 4 binding rules, and the callback bug that trips up every dev.
Read article
Tien Nguyen
· 9 min read
Material UI vs Tailwind CSS: what you inherit vs build
Material UI vs Tailwind CSS: MUI gives you Material Design plus an Emotion runtime; Tailwind compiles to zero-runtime CSS, at ~10x MUI's npm downloads.
Read article
Tien Nguyen
· 7 min read
Is shadcn/ui worth it? The honest 2026 tradeoff
Is shadcn/ui worth it? You own the code: full control and no lock-in, but you maintain it. The honest tradeoff, the Base UI switch, and when to skip it.
Read articleLiked this? Let's build something.
I take on a small number of front end and AI-engineering projects, and I'm always up for talking shop. Tell me what you're working on.