JS Fundamentals at Depth

Closures, this, prototypes, TDZ, ESM vs CJS, event delegation. One axis ties most of it together: lexical (decided by where code is written) vs runtime (decided by how it's called). Get that split and the trick questions stop being tricks.

1The frame — lexical vs runtime

Most JS “gotchas” come from confusing two questions the language answers at different times: [MDN]

Lexical / static — “where is it written?”

fixed at author time

Scope, hoisting/TDZ, and closures are all decided by the physical position of code. A closure captures the scope it was defined in — calling it from elsewhere changes nothing.

Runtime / dynamic — “how is it called?”

decided at call time

this and the prototype chain are resolved when the code runs. this depends on the call site, not where the function was written (the one exception: arrows).

Then two more that are really build/architecture concerns: the module system (ESM vs CJS, §6) and event delegation (§7). Keep the frame in hand and each section is a corollary.

One-liner

“Half of JS confusion is one mix-up: closures are lexical — fixed by where code is written; this is dynamic — fixed by how it's called. Different questions, answered at different times.”

2Scope, hoisting & the TDZ — var / let / const

varlet / const
Scopefunctionblock { }
Hoistinghoisted & initialized to undefined — read before declaration → undefinedhoisted but uninitialized — read before declaration → ReferenceError (the TDZ)
Re-declareallowednot in same scope

The Temporal Dead Zone is the window from the start of a block until the let/const declaration line: the binding exists (it's hoisted) but touching it throws. It exists to catch use-before-declare bugs that var silently swallowed. [MDN] And const freezes the binding, not the value: const a = []; a.push(1) is fine; a = [] throws.

One-liner

var hoists to undefined; let/const hoist into the TDZ and throw if you touch them early. const locks the binding, not the object — you can still mutate the array.”

3Closures — a function + the scope it remembers

A closure is a function bundled with the lexical environment it was declared in — it keeps access to those outer variables even after the outer function has returned. It's the mechanism behind data privacy, function factories, and React's useState. [MDN]

function counter() {
  let n = 0;                 // private — only the returned fns can see it
  return { inc: () => ++n, get: () => n };
}
const c = counter(); c.inc(); c.inc(); c.get();  // → 2

The interview classic — predict the logs:

for (var i = 0; i < 3; i++) setTimeout(() => console.log(i));   // 3 3 3
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i));   // 0 1 2

With var there's one i shared by all three closures; by the time the timers fire (next macrotask, Lesson 11), the loop has finished and i is 3. With let, each iteration gets a fresh block-scoped binding, so each closure captures its own i. Same lexical rule, different bindings.

Trade-off — closures keep things alive. A closure holds a reference to its entire captured scope, so those variables (and anything they point to — DOM nodes, big arrays) can't be garbage-collected while the closure lives. A long-lived closure (an un-removed event handler, a cached callback) is a classic memory-leak source (Lesson 08). Power and footgun are the same mechanism.
One-liner

“A closure is a function that remembers where it was born. var in a loop shares one binding — 3 3 3; let gives each iteration its own — 0 1 2.”

4this — decided by the call site

The most-asked and most-missed. this is not about where a function is written — it's about how it's called. Four rules, highest precedence first: [MDN]

Call formthis is…
new Fn()the brand-new instance
fn.call(o) / .apply(o) / .bind(o)explicitly o
obj.fn()the object before the dot — obj
fn() (bare)undefined in strict mode (else the global object)

Arrow functions break the rule — on purpose. An arrow has no this of its own; it uses the this of the enclosing lexical scope (back to the frame). That's why arrows are perfect for callbacks inside a method. The classic bug and its fixes:

const obj = { name: 'A', hi() { return this.name; } };
const f = obj.hi;  f();        // ✗ undefined — bare call, lost `this`
setTimeout(obj.hi, 0);          // ✗ same problem — passed as a plain fn
setTimeout(() => obj.hi(), 0);    // ✓ arrow keeps the obj.hi() call form
setTimeout(obj.hi.bind(obj), 0); // ✓ bind pins `this` = obj
One-liner

this is set by the call site, not the definition — obj.fn() binds obj, a bare fn() loses it. Arrows are the exception: no own this, they borrow the enclosing scope's.”

5Prototypes — objects linked to objects

JS inheritance is prototypal, not classical. Every object has a hidden link ([[Prototype]]) to another object; reading a property walks up that chain until it's found or the chain hits null. [MDN]

arr.map(...)        // arr → Array.prototype (map lives here) → Object.prototype → null

class is syntactic sugar over this. A class's methods live once on the prototype (shared by all instances), not copied per object — which is why they're memory-efficient. extends just links one prototype to another; instanceof checks whether a prototype appears in the chain. Knowing it's prototypes underneath explains why you can monkey-patch Array.prototype and why method lookup is a chain walk.

One-liner

“Inheritance is objects linked to objects — property lookup walks the prototype chain. class is just sugar; methods live once on the prototype, shared across instances.”

6Modules — ESM vs CJS (and why tree-shaking needs ESM)

Two module systems, and the difference is static vs dynamic — which is exactly why one tree-shakes and the other can't (Lesson 05). [Node]

ESM — import/exportCJS — require/module.exports
Resolvedstatically, before execution (imports hoisted)at runtime (a require() is just a function call)
Loadingasynchronoussynchronous
Bindinglive — import reflects later changes to the exporta copy of the value at require time
Modestrict by default; top-level awaitsloppy by default
Tree-shakeable?yesnot reliably

Why the last row: because ESM import/export are static, a bundler can tell at build time exactly which exports are used without running the code, and drop the rest. CJS's require is a dynamic call that can be conditional or computed (require(someVar)), so the bundler can't safely prove an export is unused. Static structure is the prerequisite for tree-shaking (Lesson 05's sideEffects:false lives here too).

One-liner

“ESM is static and analyzable; CJS is a runtime function call. That's the whole reason ESM tree-shakes and CJS doesn't — the bundler can see what's used without running it.”

7Event delegation — one listener, many children

Events bubble: a click on a child fires on the child, then its parent, up the ancestors (Lesson 11 — events are macrotasks). Delegation exploits that: attach one listener on a common ancestor and read event.target (often via .closest()) to find which child was hit. [MDN]

list.addEventListener('click', (e) => {
  const item = e.target.closest('li');
  if (item) select(item.dataset.id);     // one handler for all current AND future <li>
});

Why a Lead reaches for it: one listener instead of N (less memory and setup — ties to Lessons 07/08), and it automatically covers dynamically added/removed children — perfect for a virtualized hotel list or an infinite-scroll feed where rows come and go. Caveats: only works for events that bubble (most do; focus/blur don't — use focusin), and you must filter target so unrelated clicks are ignored.

One-liner

“Don't wire 500 row listeners — put one on the list and use bubbling + event.target.closest(). Fewer listeners, and it just works for rows added later.”

Capturing vs bubbling — the round trip that makes delegation work

“Bubbling” is half of a three-phase round trip every event makes through the DOM: [MDN]

  1. Capture — travels down from the root: window → document → … → target's parent.
  2. Target — reaches the element you actually hit.
  3. Bubble — travels back up: target → … → document → window.

addEventListener defaults to the bubble phase — which is exactly why delegation works (the child's click bubbles up to your parent listener). Opt into capture with a flag; capture listeners fire first (top-down), so they're for intercepting an event before the target sees it:

parent.addEventListener('click', fn);                // bubble (default)
parent.addEventListener('click', fn, { capture: true }); // capture — fires before target
// order: capturing (outer→inner) → target → bubbling (inner→outer)
You'll be askedAnswer
target vs currentTargettarget = what triggered it (the clicked child); currentTarget = what the handler is on (the parent). Why delegation uses e.target.closest().
stopPropagation vs stopImmediatePropagationFirst halts the trip but other listeners on the same element still run; second also stops those.
Delegating non-bubbling events (focus/blur)Use the bubbling twins focusin/focusoutor listen in the capture phase (capture works even though they don't bubble).
One-liner

“Events capture down to the target then bubble back up. addEventListener defaults to bubbling — that's what makes delegation work; I use capture when an ancestor must intercept first, or to delegate non-bubbling events like focus.”

8The Lead framing

Full loop

Concept: these fundamentals split cleanly into lexical (scope/closures/TDZ) and runtime (this/prototypes), plus the module + event layers. Trade-off: the same closure that gives privacy can leak memory; the flexibility of this is also its #1 bug source — so I set conventions that remove the footgun. Anchor: “We standardised on arrow class fields and useCallback discipline to kill this-binding and stale-closure bugs, and ESM-only so the bundler could tree-shake — measurable bundle drop.” Impact: juniors write these bugs daily; codifying the mechanics into lint rules and conventions is how a Lead raises a whole team's baseline. Invite: “In a TS codebase I lean on the compiler to catch a lot of this; in plain JS I'd invest more in lint rules and code review for the this and closure traps.”

9Check yourself — scenario quiz

Pick an answer; instant feedback. Push-back style, like the round.

1. The single mental model that separates closures from this:

2. What's logged?

scn: console.log(x); var x = 1; console.log(y); let y = 2;

3. const user = { name: 'A' }; user.name = 'B'; — does the second line throw?

4. for (var i=0;i<3;i++) setTimeout(()=>console.log(i)); logs 3 3 3. Why, and how to get 0 1 2?

5. In one sentence, what is a closure?

6. const f = obj.method; f(); — why is this wrong inside, and what fixes it?

7. How does an arrow function determine its this?

8. Where do a class's methods actually live, and why does it matter?

9. Why can a bundler tree-shake ESM but not (reliably) CommonJS?

10. Another ESM-vs-CJS difference a Lead should name (beyond tree-shaking):

11. A virtualized list adds/removes hundreds of <li> rows as the user scrolls. Best way to handle row clicks?

12. The Lead framing for these fundamentals on a team is…

13. Your delegated listener on the list works for clicks but not for focus events on the rows. Why, and how do you fix it?

0 / 13 answered

Try this aloud before next session: “Define a closure and a stale-closure bug. Explain why obj.method passed as a callback loses this and two ways to fix it. Then: why does ESM tree-shake when CommonJS can't?” Time to 90 seconds.
Good follow-up topics:
“Quiz me out loud, harder” “Stale closures in React hooks?” “call vs apply vs bind?” “How does `new` actually work, step by step?” “Dual-package ESM/CJS hazard?” “Capturing vs bubbling phase?”