Skip to content
Chapter 2Lesson 7

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.

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 3

The 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() sees outer’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. const stops the binding from being pointed at a new box, but a const array can still be mutated in place, and the closure sees that mutation. This is the same rule as in the lesson on const and 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.

Outer scope count 0 Inner function (closure) () => console.log(count) captures by reference
Define. The closure stores a pointer to the count binding in the outer scope, not a copy of the value 0.
Outer scope count 5 Inner function (closure) () => console.log(count)
The outer scope reassigns count to 5. The closure's pointer doesn't move; it still aims at the same binding. No snapshot was taken.
Outer scope count 5 Inner function (closure) () => console.log(count) call logs: 5
Call time, not write time. When the closure runs, it follows its pointer and reads whatever the binding holds now: 5.

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:

  1. var i is function-scoped. The whole loop shares one i binding: one box, not three.
  2. Each callback closes over that same binding, not over the value it held at iteration time.
  3. By the time the timers fire, the loop has run i up to 3. All three callbacks read through their pointer and find 3.

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 3

One 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.

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));
}

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 only

The 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.

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(); // 1
next(); // 2
next(); // 3

count 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.

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 call
let name = 'first';
const greet = () => console.log(name);
name = 'second';
greet();