Closures: lexical capture by reference
The JavaScript closure model that predicts what a function sees when it runs, and the bugs it explains across loops, React effects, and Server Actions.
A junior writes a loop that schedules three timeouts. The body looks correct: for (var i = 0; i < 3; i++) setTimeout(() => console.log(i), 0). They predict 0 1 2, but the actual output is 3 3 3. The bug isn’t in setTimeout. It’s in what each callback read when it finally ran: the same i the loop had finished mutating, not the i each callback was scheduled with.
Every function you’ve written since the chapter on the JavaScript value model is already a closure. This lesson gives you the rule that predicts what a closure sees when it runs, which is not necessarily what was there when it was defined. The rule has three parts, and once they’re in place the loop bug above stops being mysterious. You’ll then recognize the same shape in three places from later units: React effect cleanups, Server Action module-scope captures, and route-handler factories.
There’s no new syntax to learn. The whole lesson is about building the mental model.
The bug that motivates the model
Section titled “The bug that motivates the model”Read this snippet. Before you run it, predict the output.
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0);}// Predicted: 0 1 2// Actual: 3 3 3The three callbacks all ran after the loop finished, and they all read the same i. By then the loop had run i up to 3. So that’s the value they saw: not the value i had when each callback was scheduled, but the value i held when each callback finally ran.
A closure is the technical name for this. The callback inside setTimeout is a closure over the outer i, so the question “what does it print?” is really “what does the closure see when it runs?” The next section answers that second question.
Lexical capture, by reference, of the whole environment
Section titled “Lexical capture, by reference, of the whole environment”Here’s the model, stated once:
A closure is a function bundled with the lexical environment where it was defined: the bindings that were in scope at that spot in the source. When the function runs, it reads those bindings themselves, not snapshots of the values they held.
The model has three parts, and each one carries weight. Read them slowly, because everything else in this lesson applies them.
-
Lexical: write-time, not call-time. What a closure can see is fixed by where the function was written, not where it’s called from. A function defined inside
outer()seesouter’s locals from wherever it’s later invoked: another module, a timeout, a network response. The scope chain is decided at write time, so no matter how far away the call site ends up, the closure still reads from the scopes it was written inside. -
By reference, not by value. The closure holds a reference to the binding, not a copy of the value at the moment it was defined. In the binding-and-box terms from the chapter on the JavaScript value model, the closure holds onto the box, not the value sitting in it right now. When the outer scope changes the box’s contents, the closure sees the new contents on its next call.
conststops the binding from being pointed at a new box, but aconstarray can still be mutated in place, and the closure sees that mutation. This is the same rule as in the lesson onconstand mutation. -
The whole environment, not just what’s named. A closure captures every binding in its enclosing scopes, including ones the function body never visibly uses. That’s why a closure whose enclosing scope holds a large object keeps that object alive in memory until the closure itself is dropped. An aside near the end of the lesson returns to the memory cost. For now, hold onto the rule: a closure carries the whole environment, not just the names you typed.
The diagram below shows the “by reference” part in one figure. Step through it before reading on.
The three parts come down to one sentence you can carry around: a closure holds a pointer to the binding, the body runs at call time, and call time can be much later than you think. Everything from here on is that sentence applied to specific shapes.
The stale-closure trap and the fix the language gives you
Section titled “The stale-closure trap and the fix the language gives you”Now return to the var i loop with the model in hand. Three things are true at once:
var iis function-scoped. The whole loop shares oneibinding: one box, not three.- Each callback closes over that same binding, not over the value it held at iteration time.
- By the time the timers fire, the loop has run
iup to3. All three callbacks read through their pointer and find3.
The fix isn’t a clever workaround. It’s a change to which scope holds the binding. let and const are block-scoped, so each iteration of the loop creates a fresh i binding, a fresh box, and each callback closes over a different one. The for...of form you met in the lesson on flat control flow gives you this behavior for free.
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0);}// Output: 3 3 3One binding for the whole loop. var is function-scoped, so the whole loop shares a single i binding. All three callbacks point at that one binding, and by the time they fire the loop has run i up to 3. They all read the same 3.
for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0);}// Output: 0 1 2
for (const item of [0, 1, 2]) { setTimeout(() => console.log(item), 0);}// Output: 0 1 2A fresh binding per iteration. let and const are block-scoped, so each iteration creates a fresh i (or item) binding, and each callback closes over a different one. The for...of form gives this for free, since per-iteration block scope is built in. The default you learned in the flat-control-flow lesson earlier in this chapter is already the right shape.
One practical point is worth stating plainly. In a 2026 codebase you’ll essentially never write var, and for...of along with .map and .forEach callbacks all give per-iteration block scope. So the stale-closure-in-loop bug is effectively extinct in modern code, because the language already fixed it. The model still matters because the same shape surfaces in three production sites where no loop fix can help. The exercise below comes first, then those three sites.
You’re writing makeCounters(n), which builds an array of n functions. Each one should console.log its own index when called — so makeCounters(3) returns three functions that print 0, 1, 2 respectively. Which implementations behave correctly and are idiomatic in a 2026 codebase? Select all that apply.
const counters = [];for (var i = 0; i < n; i++) { counters.push(() => console.log(i));}const counters = [];for (let i = 0; i < n; i++) { counters.push(() => console.log(i));}const counters = Array.from( { length: n }, (_, i) => () => console.log(i),);const counters = [];for (const i in Array(n).fill(null)) { counters.push(() => console.log(i));}let-based for loop and the Array.from form. Each callback needs its own captured binding. let gives block-scoped per-iteration i, so each callback closes over a different one. Array.from’s callback parameter is the same idea spelled with .map-style per-iteration scope — each invocation has its own i parameter. The var version shares one i across the whole loop, so every callback reads the final value n. The for...in version is the trap from the chapter on flat control flow: it iterates string-keyed enumerable properties (including inherited ones from the prototype chain), and it produces string indices, not numeric ones — wrong reach for arrays even when it happens to print the right digits.Why this bug class surfaces in three places you’ll see again
Section titled “Why this bug class surfaces in three places you’ll see again”The loop fix is done, but the model keeps earning its weight. The same shape shows up in three places the rest of this course depends on: a function captured at one moment, invoked later, reading bindings whose values have moved in between. None of the three is a loop. Each one sets a reasonable expectation, that the function will read the value you’re looking at now, against how closures actually behave: the function reads the binding through a pointer whenever it runs. You’ll meet each place in its own chapter. This section teaches you the shape so the later chapters don’t have to derive it again.
useEffect cleanups see the previous render’s bindings
Section titled “useEffect cleanups see the previous render’s bindings”In React, useEffect runs a setup function after the component renders, and that setup captures the props and state of the render it ran in. When the effect re-runs or the component unmounts, the cleanup function React calls is the one returned by the render that created it. So it reads through pointers into that render’s bindings, not the latest ones. Here is the classic shape, in pseudocode you’ll meet for real in Unit 3:
useEffect(() => { const id = setInterval(() => console.log(count), 1000); return () => clearInterval(id);}, []); // empty deps → closure captures `count` from first render onlyThe interval callback closes over count from the render that ran the effect. With empty deps, that’s the first render, and that binding never changes. Every later render’s new count is invisible to the timer, which keeps logging whatever count was at mount.
The fix, current as of React 19.2 (shipped October 2025), is useEffectEvent. Reach for it when the effect needs to read the latest props or state without re-subscribing. It captures the latest values without joining the dependency array, and the closure model is what lets its underlying mechanism work. The full effect lifecycle and the useEffectEvent API live in the chapter on React effects in Unit 3. (The useRef workaround you’ll see in older codebases is now legacy.)
Server Actions can’t capture per-request data at module scope
Section titled “Server Actions can’t capture per-request data at module scope”A Server Action exported from a module is defined once, at module load. Suppose the file tries to capture per-request state in its outer scope, such as cookies(), headers(), or the request’s user. At module-load time those bindings either don’t exist yet or, worse, hold values from whichever request happened to load the module. Module scope lives closer to build time than to request time, so per-request data must be read inside the action body, where each call resolves its own request context.
const user = await getCurrentUser();
export const archiveInvoice = async (id: string) => { if (user.role !== 'admin') throw new Error('forbidden');};getCurrentUser() runs once, at the moment the module loads. The action closes over that single user binding, so every later request reads whatever user happened to be when the module was first imported. At best the value is wrong. At worst the authorization check lets every caller in as whoever loaded the file first.
export const archiveInvoice = async (id: string) => { const user = await getCurrentUser(); if (user.role !== 'admin') throw new Error('forbidden');};getCurrentUser() runs per call, inside the body, so each invocation gets a fresh value from its own request context. The closure still captures the surrounding module scope, but user isn’t part of it: user is a local in this call’s stack frame, and every call has its own.
The five-seam Server Action shape, including this module-scope rule, lives in the chapter on Server Actions in the forms and validation unit.
Route-handler factories close over their config
Section titled “Route-handler factories close over their config”Higher-order functions that produce route handlers are the third site, and the one where closures are the feature rather than the bug. A factory like withRole('admin', handler) returns a new handler that has captured both the role string and the inner handler in its lexical scope. Each call to the factory produces a fresh handler closure with its own captured config, and the closure model is what lets the pattern compose.
const withRole = (role: string, handler: (req: Request, user: User) => Promise<Response>) => async (req: Request) => { const user = await requireUser(req); if (user.role !== role) return new Response('forbidden', { status: 403 }); return handler(req, user); };
export const POST = withRole('admin', archiveInvoice);The returned async function closes over both role and handler. Calling withRole('admin', archiveInvoice) and withRole('member', listInvoices) produces two different closures, each carrying its own captured config. This is the foundation of the wrapper idioms (safeAction, authedAction, requireRole) covered in the chapter on typed wrappers in Unit 1 and applied at the route-handler surface in Unit 4. It’s the same model as the bug, used on purpose: a function bundled with the bindings you wanted it to remember.
Closures as a design tool, not just a bug source
Section titled “Closures as a design tool, not just a bug source”The framing so far has been all bugs, but closures are also the language’s main way to hide state behind a function boundary. In a 2026 SaaS codebase you’ll write essentially no classes. Private state almost always lives in a module’s lexical scope, and the exported functions are closures over that state. The classic illustration is a counter factory:
const makeCounter = () => { let count = 0; return () => ++count;};
const next = makeCounter();next(); // 1next(); // 2next(); // 3count lives inside makeCounter’s scope. Nothing outside the closure can read it, write it, or even see that it exists. The returned function is the only surface, and it’s a closure over count. The same mechanism is how useState keeps a component’s state private under the hood (covered in the chapter on the React render model in Unit 3), how memoization helpers cache results, and how every middleware-style wrapper holds its config. The course stays functional throughout, and closures are why that works without giving up encapsulation.
Closing exercise: predict the closure
Section titled “Closing exercise: predict the closure”The program below packs three small scenarios into one. Predict every line it prints, in order. It’s your check that the model is in place before the next chapter on containers.
Predict every line this program prints, in order. The scenarios build from easier to harder — if you get the first two right, lean on the model for the third. Predict what this program prints, then press Check.
// 1. Counter factory — does each counter have its own count?const makeCounter = () => { let count = 0; return () => ++count;};const a = makeCounter();const b = makeCounter();console.log(a(), a(), b());
// 2. The stale-closure-in-loop, now with `let`for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0);}
// 3. Outer-binding reassignment between definition and calllet name = 'first';const greet = () => console.log(name);name = 'second';greet();Scenario 1. Each call to makeCounter creates a fresh closure with its own count binding. a() increments a’s count to 1; a() again to 2; b() increments b’s separate count to 1. The line prints 1 2 1.
Scenario 2. let gives per-iteration block scope, so each scheduled callback closes over a different i binding (0, 1, 2). They print in order — but only after all synchronous code has finished.
Scenario 3. greet doesn’t snapshot name at definition; it reads through a pointer to the name binding at call time. The reassignment to 'second' happens before greet() runs, so the closure reads the current contents of the box and prints second.
Why second appears before 0 1 2. A small bonus from the event loop (covered in depth in the chapter on async and time): setTimeout(..., 0) queues a callback to run after the current synchronous code finishes. So the synchronous greet() runs before the three queued callbacks, even though the timers were scheduled first. The closure point stands either way — what each callback reads is decided by what its captured binding holds at the moment it actually runs.
External resources
Section titled “External resources”MDN's canonical closure reference — scope chains, the counter factory pattern, and the loop trap covered at language depth.
The depth-pass for students who want to go past the model this lesson installs — lexical environment internals, hoisting, the temporal dead zone, and closure mechanics at spec level.
React 19.2 (October 2025) shipped useEffectEvent stable as the canonical fix for stale closures in effects — the modern replacement for the older useRef workaround.