Scope, closures, this and prototypes in JavaScript
Why is this undefined in my callback, why do all my loop handlers see the last value, and what does class actually create?
Variables resolve lexically, by where code is written; this resolves dynamically, by how a function is called. Closures keep their defining scope alive, and class is syntax over constructor functions and a prototype chain.
THE MENTAL MODEL
Keep two lookups apart. Variable lookup is static: every function remembers the scope it was created in and walks outward (block, function, outer functions, module, global) at the moment it reads a name, so a closure sees the current value of a shared binding, not a snapshot. Lookup of this is dynamic: it is decided at each call site by how the function is invoked (new, bind, call/apply, obj.method(), or a bare call), except in arrow functions, which have no this of their own and use the enclosing one like any other variable. Property lookup is a third chain: own properties first, then [[Prototype]] links up to Object.prototype and null. Most surprising bugs (the loop that logs 3, 3, 3; the method that loses this when passed as a callback; the listener that keeps a megabyte array alive) come from mixing up which of these three lookups is in play.
HOW IT FITS TOGETHER
- Arrow function?this is the enclosing scope's this, captured at creation. call, apply, bind and new cannot change it (new throws a TypeError).
- Called with new?this is a fresh object whose [[Prototype]] is F.prototype. In a derived class constructor, this does not exist until super() returns.
- Bound with bind()?this is the bound value, permanently. A later call() or another bind() cannot override it; only new can.
- call() or apply()?this is the first argument. In sloppy mode null and undefined become globalThis and primitives are boxed; in strict mode it is passed as is.
- Called as obj.method()?this is the object left of the dot or bracket at call time, not the object the function was defined on. const f = obj.method; f() loses it.
- Plain call: fn()undefined in strict code (modules, classes, "use strict"), globalThis in sloppy scripts. Callbacks passed to other APIs usually land here unless the API takes a thisArg.
- Block scopelet, const and class declared inside { }, including a fresh binding per iteration of for (let ...). In the TDZ until the declaration runs.
- Function scopeParameters, var declarations and function declarations, all hoisted to the top of the function. Also arguments and this for non-arrow functions.
- Outer function scopesWhat a closure keeps alive after the outer function returns. Captured by reference: later writes are visible to every closure sharing the scope.
- Module scopeTop-level bindings of an ES module, private to the module. Imports are live read-only bindings to the exporter's variables.
- Global scopeScript-level let/const in a declarative record, plus globalThis properties from var, function declarations and the host (window, process).
| this | arguments | Callable with new | Hoisting | |
|---|---|---|---|---|
| function declaration | Dynamic, set by the call site | Own | Yes | Name and body hoisted: callable before its line |
| function expression | Dynamic, set by the call site | Own | Yes | Only the variable: var is undefined, let/const in TDZ |
| Arrow function | Lexical, from enclosing scope | None; sees enclosing one | No: TypeError, no prototype | Only the variable it is assigned to |
| Object method shorthand | Dynamic: the receiver | Own | No: TypeError | Created when the object literal is evaluated |
| Class method | Dynamic; strict, so undefined when detached | Own | No: TypeError | Shared on the prototype, created with the class |
| Class constructor | New instance; in derived classes after super() | Own | Only with new; plain call throws | Declaration in TDZ until evaluated |
| Arrow class field | Lexical: always the instance | None | No: TypeError | New closure per instance, own property not prototype |
KEY TERMS
- Lexical scope
- Which variables a function can see is fixed by where it is written, not where it is called. Each function and block creates an environment linked to its parent; name lookup walks that chain outward at read time.
- Hoisting and the temporal dead zone
- All declarations are registered when a scope is entered. var is initialized to undefined, function declarations to their function, while let, const and class stay uninitialized until their line runs; touching them earlier, even with typeof, throws a ReferenceError. The TDZ is about execution time, not text position.
- Closures
- A function plus the environment it was created in. The environment is held by reference, so closures share and see later writes to captured bindings. That gives private state and factories, and it is also the root of the var-in-a-loop bug: one binding, many readers.
- this binding
- Determined per call: new, then bind, then call/apply, then method call, then plain call (undefined in strict code). Arrow functions skip the rules and use the enclosing this. Passing obj.method as a callback passes the function, not the object.
- Prototype chain
- Every object has a [[Prototype]] link. Property reads check own properties, then walk the chain to Object.prototype and finally null. Writes create or update an own property, shadowing the inherited one rather than changing it.
- class is mostly sugar
- A class creates a constructor function whose prototype holds the methods; extends links both the prototypes and the constructors. Real differences: class bodies are strict, methods are non-enumerable, constructors throw without new, declarations sit in the TDZ, private #fields are enforced, and derived instances are created by the base constructor via super().
- Closures and memory
- Garbage collection frees what is unreachable. A closure keeps its captured scope reachable for as long as the closure itself is reachable, so long-lived references (event listeners, timers, caches, module-level arrays) are what turn a closure into a leak.
IN YOUR STACK
JavaScript · Before: var, after: let Client + server
const handlers = [];
for (var i = 0; i < 3; i++) {
handlers.push(() => i); // every closure reads the same function-scoped i
}
console.log(handlers.map((h) => h())); // [3, 3, 3]
const fixed = [];
for (let j = 0; j < 3; j++) {
fixed.push(() => j); // let creates a fresh binding per iteration
}
console.log(fixed.map((h) => h())); // [0, 1, 2]
// The async form of the same bug: logs 3, 3, 3
for (var k = 0; k < 3; k++) setTimeout(() => console.log(k), 0);
// Closures share live bindings; they do not snapshot values
function counter() {
let count = 0;
return { inc: () => ++count, read: () => count };
}
const c = counter();
c.inc();
c.inc();
console.log(c.read()); // 2: both closures share one count- The callbacks run after the loop finished, when the single var binding already holds 3. let gives each iteration its own copy initialized from the previous one.
- The same mistake appears with var inside async loops, event handler setup and React effects that capture stale state: always ask which binding the closure reads, and when.
JavaScript · Hoisting and the TDZ Client + server
console.log(typeof declared); // "function": declaration hoisted with its body
console.log(v); // undefined: var hoisted, initialized to undefined
try {
console.log(l); // binding exists but is uninitialized
} catch (err) {
console.log(err.name); // "ReferenceError": temporal dead zone
}
var v = 1;
let l = 2;
function declared() {}
// The TDZ is about time, not position
function readLater() {
return late;
}
const late = "ok";
console.log(readLater()); // "ok": called after initialization
// Class declarations are in the TDZ too
try {
new Early();
} catch (err) {
console.log(err.name); // "ReferenceError"
}
class Early {}- Prefer const, then let; var is function-scoped, ignores blocks and creates properties on globalThis in scripts.
- TDZ errors in production often come from circular ES module imports: a module reads an imported const or class before the exporting module finished evaluating.
JavaScript · this: lost receivers and fixes Client + server
class Counter {
count = 0;
increment() { return ++this.count; }
incrementBound = () => ++this.count; // arrow field: lexical this, per instance
}
const c = new Counter();
const detached = c.increment;
try {
detached(); // plain call in strict class code: this is undefined
} catch (err) {
console.log(err instanceof TypeError); // true
}
console.log(c.increment()); // 1: receiver is c
console.log(detached.call(c)); // 2: explicit this
const bound = c.increment.bind(c);
console.log(bound.call({ count: 100 })); // 3: bind beats call
const { incrementBound } = c;
console.log(incrementBound()); // 4: arrow keeps the instance
const report = {
name: "Q3",
titles(rows) { return rows.map((r) => `${this.name}: ${r}`); }, // arrow: outer this
broken(rows) { return rows.map(function (r) { return this?.name ?? "lost"; }); },
};
console.log(report.titles(["a"]), report.broken(["a"])); // ["Q3: a"] ["lost"] in a module- Passing c.increment to addEventListener, setTimeout or a framework prop passes the bare function. Bind it, wrap it in an arrow, or use an arrow class field.
- Arrow class fields are own properties created per instance, so they cost one closure per object and cannot be overridden through the prototype or reached with super.
- Never use an arrow function as an object or prototype method that needs the receiver: its this is whatever surrounded the object literal.
JavaScript · class vs prototypes Client + server
class Animal {
constructor(name) { this.name = name; }
speak() { return `${this.name} makes a sound`; }
}
class Dog extends Animal {
speak() { return `${super.speak()} (woof)`; }
}
// Roughly the same wiring without class syntax
function Animal2(name) { this.name = name; }
Animal2.prototype.speak = function () { return `${this.name} makes a sound`; };
function Dog2(name) { Animal2.call(this, name); }
Object.setPrototypeOf(Dog2.prototype, Animal2.prototype); // instance chain
Object.setPrototypeOf(Dog2, Animal2); // static chain
Dog2.prototype.speak = function () {
return `${Animal2.prototype.speak.call(this)} (woof)`;
};
const rex = new Dog("Rex");
console.log(Object.getPrototypeOf(rex) === Dog.prototype); // true
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype); // true
console.log(Object.hasOwn(rex, "name"), Object.hasOwn(rex, "speak")); // true false
console.log(new Dog2("Max").speak()); // "Max makes a sound (woof)"- Methods live once on the prototype and are found by walking the chain; only fields assigned in the constructor or declared as class fields are own properties.
- The desugared version differs in the details class guarantees: strict mode, non-enumerable methods, a TypeError when called without new, and the base constructor creating the instance (which is why extending Array or Error works properly only with class).
JavaScript · Browser: leak and fix Client
function buildReport() {
return Array.from({ length: 1_000_000 }, (_, i) => ({ row: i }));
}
// Leak: the listener closes over report, and the button lives for the whole session
function attachLeaky(button) {
const report = buildReport();
button.addEventListener("click", () => console.log(report.length, "rows"));
}
// Fix: capture only what the callback needs, and make the listener removable
function attachFixed(button, signal) {
const rowCount = buildReport().length; // the big array is garbage after this line
button.addEventListener("click", () => console.log(rowCount, "rows"), { signal });
}
const controller = new AbortController();
attachFixed(document.querySelector("#export"), controller.signal);
// On teardown (route change, component unmount, dialog close):
controller.abort(); // removes the listener, so its closure becomes unreachable- Closures are not leaks by themselves; reachability is. Listeners on long-lived elements, setInterval callbacks, module-level caches and pending promises are the usual roots.
- In V8, closures created in the same scope share one context object, so a variable captured by any closure there stays alive as long as any of them does. Narrow what you capture rather than relying on the engine to trim it.
- Confirm with a heap snapshot: compare snapshots before and after repeating the action, then follow the retainers path to the closure's context.
WHERE IT BITES
- Passing a method as a callback (onClick={this.save}, setTimeout(obj.tick, 100), promise.then(service.handle)): the receiver is dropped and this becomes undefined in strict code.
- Using arrow functions for object or prototype methods. They cannot see the receiver, and fixing that with arrow class fields trades it for a closure per instance.
- Capturing a var loop variable in async callbacks, or a stale value in a long-lived closure such as a React effect or an interval. The closure reads the binding when it runs, not when it was created.
- ReferenceError from the TDZ at startup, typically from circular imports or a class referenced before its declaration was evaluated. It only shows up in certain import orders, so bundling changes can surface it.
- Long-lived closures retaining large data: listeners never removed, intervals never cleared, memoization caches without bounds. Use AbortController signals, clear timers on teardown and WeakMap for per-object caches.
- Mutating shared prototypes (adding methods to Array.prototype or Object.prototype). Every object in the realm sees it, including other libraries, and it can break future built-ins with the same name.
CLOSE THE AI. EXPLAIN THIS.
Given const { save } = form; button.addEventListener("click", save), explain what this is inside save, which rule decided it, and give three fixes with their trade-offs. Then explain why for (var i...) setTimeout(() => log(i)) prints the same number three times.SOURCES
Explainer reviewed