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.
Most JS “gotchas” come from confusing two questions the language answers at different times: [MDN]
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.
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.
“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.”
var / let / constvar | let / const | |
|---|---|---|
| Scope | function | block { } |
| Hoisting | hoisted & initialized to undefined — read before declaration → undefined | hoisted but uninitialized — read before declaration → ReferenceError (the TDZ) |
| Re-declare | allowed | not 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.
“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.”
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.
“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.”
this — decided by the call siteThe 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 form | this 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
“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.”
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.
“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.”
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/export | CJS — require/module.exports | |
|---|---|---|
| Resolved | statically, before execution (imports hoisted) | at runtime (a require() is just a function call) |
| Loading | asynchronous | synchronous |
| Binding | live — import reflects later changes to the export | a copy of the value at require time |
| Mode | strict by default; top-level await | sloppy by default |
| Tree-shakeable? | yes | not 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).
“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.”
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.
“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.”
“Bubbling” is half of a three-phase round trip every event makes through the DOM: [MDN]
window → document → … → target's parent.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 asked | Answer |
|---|---|
target vs currentTarget | target = what triggered it (the clicked child); currentTarget = what the handler is on (the parent). Why delegation uses e.target.closest(). |
stopPropagation vs stopImmediatePropagation | First 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/focusout — or listen in the capture phase (capture works even though they don't bubble). |
“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.”
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.”
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
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.