GoByte Skills Episode 17 of 27, track JavaScript (2 of 3)

Closures: functions remember where they were born

GoByte Skills #17: A closure captures the variable, not the value. That is why three callbacks in a var loop all print 3 and the let loop prints 0, 1, 2.

Zoe is one of GoByte's characters. This post was drafted by AI agents in Zoe's voice, then fact checked, run and edited by the GoByte team.

Back to top

A loop with var and a loop with let behave differently because var gives the whole loop one shared binding, while let gives each iteration its own. A closure captures the variable itself (the binding), not the value it held when the function was created.

Animation for GoByte Skills #17: Three callbacks capture one shared variable, then three separate ones.
Transcript

Three callbacks from a var loop draw lines to one shared box that ends at 3, then three callbacks from a let loop each draw a line to their own box holding 0, 1 and 2, because a closure captures the binding.

const a = [];
const b = [];
for (var i = 0; i < 3; i++) a.push(() => i);
for (let j = 0; j < 3; j++) b.push(() => j);

console.log(a.map((f) => f())); // shared i
console.log(b.map((f) => f())); // one j each

Node 20 prints:

[ 3, 3, 3 ]
[ 0, 1, 2 ]

The mechanism#

Every function object holds a reference to the environment it was created in, and looks variables up there when it runs, not when it was born. var is scoped to the enclosing function, so the first loop has exactly one i. All three arrows point at it, and by the time they run the loop has left it at 3.

let in a for header is special in the spec: each iteration gets a fresh copy of j, initialized from the previous one, before the increment runs. Three iterations, three bindings, three arrows that each remember their own. let arrived in ES2015; before that, the fix was an IIFE per iteration, (function (i) { ... })(i), which creates the fresh scope by hand.

Still a binding, not a snapshot#

The per-iteration copy is a variable, not a frozen value. If the loop body changes j after pushing the arrow, that arrow sees the change. Closures read variables late, always.

Why it matters beyond loops#

This is how JavaScript does private state without classes: function counter() { let n = 0; return () => ++n; }. Nothing outside can reach n, yet it lives as long as the returned function does.

The cost#

A closure keeps alive what it references. An event listener that closes over a 50 MB array keeps that array until the listener is removed, because the listener can still read it, so the garbage collector must keep it. Remove listeners, or close over the small thing you need instead of the big thing that has it.

Rule of thumb#

Ask which variable a callback captures and how many copies of it exist. One var shared by three closures is a group chat: everyone reads the last message.

Report a mistake

Your product here? Partner with us

Back to top

Discussion

No comments yet. Signed in GoByte members with a verified e-mail can join. Community guidelines

Reading is open to everyone. Commenting and voting need a GoByte account with a verified e-mail.