JavaScript Part 2

The DOM and Events

The DOM tree, changing nodes, capturing and bubbling traced, event delegation, custom events and forms, run with jsdom.

Lesson 0528 min read#dom#events#delegation

On this page

  1. Words used in this lesson
  2. Part 1. The DOM is a tree of objects
  3. Part 2. Creating and changing nodes
  4. Part 3. textContent vs innerHTML
  5. Part 4. Event listeners: capture, target, bubble
  6. Part 5. Event delegation
  7. Part 6. Custom events
  8. Part 7. Form events
  9. Part 8. What jsdom does not do
  10. Try it
  11. Interview questions
  12. Key takeaways
  13. What's next

In one line: the browser turns your HTML into a tree of JavaScript objects (the DOM). You read and change that tree to change the page, and you react to the user with events that travel down and then back up the tree.

This lesson teaches the DOM from the ground up. You will find nodes, create and change them, and see why textContent and innerHTML are not the same safety level. Then comes the most asked event question: what order do handlers run in when you click a nested element? We trace it with real output. We finish with event delegation, passive listeners, custom events and form events.

All of the examples run in jsdom, a pure JavaScript copy of the DOM that runs in Node. That lets us paste real output. It is not a browser, and the last section lists clearly what it cannot do. You should know functions and callbacks (JavaScript, Lesson 12) and the this rules from Lesson 1. For how a page gets from bytes to pixels, see Browser, Lesson 1, and for the security side of innerHTML, see Browser, Lesson 7.

What I ran / checked. All code ran in Node v22.22.0 with jsdom 29.1.1 (installed from the npm registry, default settings unless a code block says otherwise). Output is pasted from real runs. I did not run anything in a real browser, so statements about browser-only behaviour (layout, scroll performance, passive listener warnings) are things I know from the standard, not things I measured. Where jsdom differs from a browser, I say so.

Words used in this lesson

  • DOM (Document Object Model) is the browser's in-memory model of the page: a tree of objects you can read and change with JavaScript.
  • Node is any item in the tree. Element is a node for an HTML tag, such as <ul>. Text and comments are also nodes, but they are not elements.
  • Selector is a CSS pattern such as li.done or #list > li. querySelector takes one.
  • Event is an object that says "something happened" (a click, a key press, a submit). Listener (or handler) is the function you register to run when it happens.
  • Target is the element where the event started (the thing the user actually clicked). Current target is the element whose listener is running right now.
  • Capturing is the trip down the tree from the window to the target. Bubbling is the trip back up from the target to the window.
  • Event delegation means putting one listener on a parent instead of one on every child.
  • XSS (cross-site scripting) is an attack where untrusted text becomes running code in your page.
  • Layout is the browser's work of computing the size and position of every box.

Part 1. The DOM is a tree of objects

Take this HTML:

HTML
<main id="app">  <h1 class="title">Hello</h1>  <ul id="list"><li>one</li><li class="done">two</li><li>three</li></ul>  <!-- note --></main>

The browser parses it and builds an object for each piece: one for <main>, one for <h1>, one for the text Hello inside it, and so on. Every object has a parent (except the top one, document), and maybe children and siblings. All of them inherit from Node, which is a prototype chain like the one in Lesson 0.

I loaded that HTML in jsdom and asked questions:

JavaScript
const { JSDOM } = require('jsdom');const dom = new JSDOM(`<!doctype html><html><body><main id="app"><h1 class="title">Hello</h1><ul id="list"><li>one</li><li class="done">two</li><li>three</li></ul><!-- note --></main></body></html>`);const { document } = dom.window;const ul = document.getElementById('list');console.log(ul.nodeType, ul.nodeName, ul.childNodes.length, ul.children.length);console.log([...ul.childNodes].map(n => n.nodeType + ':' + n.nodeName).join(' '));console.log(ul.firstElementChild.textContent, ul.lastElementChild.previousElementSibling.className);
TEXT
1 UL 3 31:LI 1:LI 1:LIone done
  • nodeType 1 means "element". Text nodes are 3 and comments are 8.
  • childNodes lists all child nodes. children lists only the elements. Here they are equal because the list has no spaces between the <li> tags. If the HTML had line breaks between tags, childNodes would also contain text nodes made of whitespace, and the two counts would differ. This surprises many people.
  • Walk the tree with parentNode, firstElementChild, nextElementSibling and friends. The ...Element... versions skip text nodes.

Finding nodes

JavaScript
console.log(document.querySelector('li.done').textContent, document.querySelectorAll('li').length);console.log(document.querySelector('.nope'));console.log(document.querySelector('li.done').closest('main').id, document.querySelector('li').matches(':first-child'));
TEXT
two 3nullapp true
MethodReturnsNotes
querySelector(sel)first match or nulltakes any CSS selector
querySelectorAll(sel)a static NodeLista snapshot; later changes do not show
getElementById(id)the element or nullfast, no # in the argument
getElementsByTagName(name) / getElementsByClassName(name)a live HTMLCollectionupdates itself when the page changes
el.closest(sel)the nearest ancestor (or itself) that matches, or nullthe key tool for delegation (Part 5)
el.matches(sel)true or falsedoes this element match the selector?

Always handle null: a missing element gives null, and null.textContent throws.

The "static versus live" difference is easy to prove:

JavaScript
const live = document.getElementsByTagName('li'), snap = document.querySelectorAll('li');ul.appendChild(document.createElement('li'));console.log('live', live.length, 'static', snap.length);console.log(Object.prototype.toString.call(snap), typeof snap.map, typeof snap.forEach, [...snap].length);
TEXT
live 4 static 3[object NodeList] undefined function 3

After adding one <li>, the live collection saw 4 and the static NodeList still said 3. Also notice that a NodeList has forEach but no map. It is iterable, so [...snap] or Array.from(snap) gives a real array (see Lesson 4 for why).

Part 2. Creating and changing nodes

JavaScript
const { document } = new JSDOM('<ul id="list"></ul>').window;const ul = document.getElementById('list');const li = document.createElement('li');li.textContent = 'first';li.classList.add('item', 'new');li.setAttribute('data-id', '7');li.dataset.kind = 'demo';ul.append(li);const li2 = li.cloneNode(true); li2.textContent = 'second'; ul.append(li2);ul.prepend(document.createElement('li'));ul.insertAdjacentHTML('beforeend', '<li class="html">third</li>');console.log(ul.outerHTML);
TEXT
<ul id="list"><li></li><li class="item new" data-id="7" data-kind="demo">first</li><li class="item new" data-id="7" data-kind="demo">second</li><li class="html">third</li></ul>

What happened, in order:

  1. createElement('li') made a node that is not in the page yet. It lives only in memory.
  2. textContent, classList, setAttribute and dataset changed it. dataset.kind is the same as the attribute data-kind.
  3. append put it into the list. prepend added an empty one at the front.
  4. cloneNode(true) copied the node and its children (true means "deep"). The copy kept the classes and data attributes.
  5. insertAdjacentHTML parsed an HTML string and inserted it at a position (beforeend means "just inside the end").

Now change and remove:

JavaScript
li.classList.toggle('new'); li.removeAttribute('data-id');console.log(li.outerHTML, li.dataset.kind, li.getAttribute('class'));ul.firstElementChild.remove(); li2.replaceWith(document.createElement('hr'));console.log(ul.innerHTML);
TEXT
<li class="item" data-kind="demo">first</li> demo item<li class="item" data-kind="demo">first</li><hr><li class="html">third</li>

toggle('new') removed the class because it was present. remove() deletes a node from its parent. replaceWith() swaps it for another.

A document fragment (document.createDocumentFragment()) is a light container outside the page. Build many nodes in it, then insert it once: its children move into the page and it is left empty. This means one change to the live page instead of many. In jsdom I checked this: a fragment holding 3 li nodes had 0 children after ul.append(frag), and the list had them. jsdom has no layout, so I did not measure any speed gain.

Part 3. textContent vs innerHTML

Both set what is inside an element. They treat the string very differently.

JavaScript
const { document } = new JSDOM('<div id="box"></div>').window;const box = document.getElementById('box');const evil = '<img src=x onerror="alert(1)"> & <b>bold</b>';box.textContent = evil;console.log('textContent ->', box.innerHTML);box.innerHTML = evil;console.log('innerHTML   ->', box.innerHTML, '| children:', [...box.children].map(c => c.tagName).join());console.log('textContent read:', JSON.stringify(box.textContent));console.log('onerror attr present:', box.querySelector('img').hasAttribute('onerror'));
TEXT
textContent -> &lt;img src=x onerror="alert(1)"&gt; &amp; &lt;b&gt;bold&lt;/b&gt;innerHTML   -> <img src="x" onerror="alert(1)"> &amp; <b>bold</b> | children: IMG,BtextContent read: " & bold"onerror attr present: true
textContentinnerHTML
The string is treated asplain textHTML to be parsed
< becomes< (shown as a character)the start of a tag
Creates child elements?no (one text node)yes (an IMG and a B here)
Reading it givesall the text, with tags removedthe HTML markup
Safe with user input?yesno

With innerHTML, the <img onerror=...> became a real element with a real onerror attribute. In a browser, the image fails to load, and the browser runs alert(1). That is XSS. I show the element exists in jsdom; I did not run the script in a browser. Browser, Lesson 7 covers the full defence, including a Content Security Policy.

The rules I follow:

  1. Use textContent (or createElement and append) for any text that came from a user, a URL, a database or an API.
  2. Use innerHTML only for markup you wrote yourself, or after cleaning it with a sanitiser library.
  3. Escaping by hand is a last resort. A small escape function turns the same string into harmless text:
JavaScript
const esc = s => s.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));console.log(esc(evil));
TEXT
&lt;img src=x onerror=&quot;alert(1)&quot;&gt; &amp; &lt;b&gt;bold&lt;/b&gt;

Setting innerHTML throws away the old children and any listeners on them. el.innerHTML += '<li>new</li>' rebuilds the whole list from a string, so every old <li> is a new object and loses its listeners. Use append or insertAdjacentHTML to add without rebuilding.

React, Vue and similar libraries escape text for you. Their "raw HTML" escape hatches (dangerouslySetInnerHTML, v-html) bring the same risk back.

Part 4. Event listeners: capture, target, bubble

The basics

el.addEventListener(type, handler, options) registers a handler. When the event happens, the browser calls handler(event). The event object has these useful fields:

FieldMeaning
type'click', 'input', 'submit', ...
targetthe element where the event started
currentTargetthe element whose listener is running now
eventPhase1 capturing, 2 at target, 3 bubbling
bubblesdoes it go back up after the target?
cancelablecan preventDefault() stop its default action?
defaultPreventedhas preventDefault() been called?

The three phases, traced

An event does not just run on the clicked element. It makes a round trip:

  1. Capturing phase. From window down to the parent of the target.
  2. Target phase. On the target itself.
  3. Bubbling phase. From the target back up to window (only if the event bubbles; click does).

A listener runs in bubbling mode by default. Pass true (or { capture: true }) as the third argument to run it in the capturing phase.

Here is a page with three nested elements, and a capture listener and a bubble listener on each:

JavaScript
const { document } = new JSDOM(`<div id="outer"><div id="middle"><button id="inner">go</button></div></div>`).window;const [outer, middle, inner] = ['outer', 'middle', 'inner'].map(id => document.getElementById(id));for (const el of [outer, middle, inner]) {  el.addEventListener('click', e => console.log(`  capture  ${el.id.padEnd(6)} target=${e.target.id} currentTarget=${e.currentTarget.id} phase=${e.eventPhase}`), true);  el.addEventListener('click', e => console.log(`  bubble   ${el.id.padEnd(6)} target=${e.target.id} currentTarget=${e.currentTarget.id} phase=${e.eventPhase}`));}console.log('click on #inner:');inner.click();
TEXT
click on #inner:  capture  outer  target=inner currentTarget=outer phase=1  capture  middle target=inner currentTarget=middle phase=1  capture  inner  target=inner currentTarget=inner phase=2  bubble   inner  target=inner currentTarget=inner phase=2  bubble   middle target=inner currentTarget=middle phase=3  bubble   outer  target=inner currentTarget=outer phase=3

Read the six lines as a path:

TEXT
outer(capture) -> middle(capture) -> inner(target: capture, then bubble) -> middle(bubble) -> outer(bubble)
  • target is inner on every line. It never changes during the trip.
  • currentTarget changes at every step: it is where the listener is attached.
  • At the target (phase=2), both listeners on inner run. In this run, the capture-flagged one ran first, then the bubble one. (In older browsers, the order at the target was the order of registration. Do not depend on the order at the target.)
  • If you want your handler to run before the children's handlers, use capture. Most code never needs it.

stopPropagation and preventDefault are different things

stopPropagation()preventDefault()
Stopsthe event moving to the next element on its paththe browser's default action (follow a link, submit a form, type a character)
Other listeners on the same elementstill runstill run
Needs cancelablenoyes

First, stopPropagation. I added a stopper on middle (bubble phase) and then, in a second try, on outer in the capture phase:

JavaScript
const stop = e => { console.log('  middle stops it'); e.stopPropagation(); };middle.addEventListener('click', stop);inner.click();
TEXT
stopPropagation at middle (bubble):  capture  outer  target=inner currentTarget=outer phase=1  capture  middle target=inner currentTarget=middle phase=1  capture  inner  target=inner currentTarget=inner phase=2  bubble   inner  target=inner currentTarget=inner phase=2  bubble   middle target=inner currentTarget=middle phase=3  middle stops it

The event reached middle. Its earlier bubble listener (the logging one) still ran, because other listeners on the same element are not stopped. Then outer's bubble listener never ran.

JavaScript
const capStop = e => { console.log('  outer capture stops it'); e.stopPropagation(); };outer.addEventListener('click', capStop, true);inner.click();
TEXT
stopPropagation at outer capture:  capture  outer  target=inner currentTarget=outer phase=1  outer capture stops it

Stopping in the capture phase at the top ended the trip before it reached the target. The button never saw the click. To also stop the other listeners on the same element, call stopImmediatePropagation(). It stops the rest of the path too:

JavaScript
const w = new JSDOM('<div id="d"></div>').window;const d = w.document.getElementById('d');d.addEventListener('click', e => { console.log('first'); e.stopImmediatePropagation(); });d.addEventListener('click', () => console.log('second'));w.document.body.addEventListener('click', () => console.log('body'));d.click();
TEXT
first

Neither the second listener on the div nor the one on body ran.

Now preventDefault. A link with a hash address changes location.hash when clicked. In jsdom, that default action happens a moment after the click, so I check after a tick:

JavaScript
const w = new JSDOM(`<a id="link" href="#x">link</a><a id="p" href="#y">p</a>`, { url: 'http://localhost/page' }).window;const p = w.document.getElementById('p');p.addEventListener('click', e => e.preventDefault());w.document.getElementById('link').click(); p.click();console.log('right after click:', JSON.stringify(w.location.hash));setTimeout(() => console.log('after a tick:', JSON.stringify(w.location.hash)), 0);
TEXT
right after click: ""after a tick: "#x"

The link with a handler that calls preventDefault() (#y) did nothing. The plain link (#x) navigated. Only preventDefault stops the default action.

dispatchEvent tells you the result. It returns false if a handler cancelled the event. But an event that is not cancelable cannot be cancelled:

TEXT
dispatchEvent returned false | defaultPrevented true

(From a cancelable click event with a handler that called preventDefault().)

Listener options

OptionEffect
capture: truelisten in the capturing phase
once: trueremove the listener after the first call
passive: truepromise to never call preventDefault()
signal: abortSignalremove the listener when the signal aborts
JavaScript
let n = 0; el.addEventListener('ping', () => n++, { once: true });el.dispatchEvent(new Event('ping')); el.dispatchEvent(new Event('ping')); console.log('once ->', n);const ac = new w.AbortController(); let m = 0;el.addEventListener('pong', () => m++, { signal: ac.signal });el.dispatchEvent(new Event('pong')); ac.abort(); el.dispatchEvent(new Event('pong')); console.log('signal ->', m);let c = 0; const g = () => c++; el.addEventListener('y', g); el.addEventListener('y', g); el.dispatchEvent(new Event('y')); console.log('same function added twice runs', c, 'time(s)');c = 0; el.addEventListener('z', () => c++); el.addEventListener('z', () => c++); el.dispatchEvent(new Event('z')); console.log('two different arrow functions run', c, 'time(s)');
TEXT
once -> 1signal -> 1same function added twice runs 1 time(s)two different arrow functions run 2 time(s)
  • once ran the handler one time out of two dispatches.
  • signal gives a neat clean-up: one abort() removes every listener that shares the signal. That is handy for components that must remove all their listeners when they go away. (Memory leaks from forgotten listeners: Lesson 7.)
  • Adding the same function twice registers it once. Two separate arrow functions are two different functions, so both run. This is why removeEventListener('x', () => {...}) with a new arrow function never works: you must pass the exact same function reference.

Passive listeners

On touch and wheel events, a browser normally has to wait and see whether your handler calls preventDefault() (which would cancel scrolling) before it can scroll. That wait can make scrolling feel slow. If you add { passive: true }, you promise not to cancel, so the browser can scroll at once. If you call preventDefault() anyway, it is ignored (browsers log a warning). I could check the "ignored" part in jsdom:

JavaScript
const cancelable = new Event('wheel', { cancelable: true });el.addEventListener('wheel', e => { e.preventDefault(); }, { passive: true });console.log('passive:', el.dispatchEvent(cancelable), cancelable.defaultPrevented);const c2 = new Event('wheel', { cancelable: true });const el2 = document.createElement('div');el2.addEventListener('wheel', e => { e.preventDefault(); });console.log('normal:', el2.dispatchEvent(c2), c2.defaultPrevented);
TEXT
passive: true falsenormal: false true

With a passive listener, preventDefault() did nothing (dispatchEvent returned true, defaultPrevented stayed false). The speed benefit is a browser behaviour that jsdom cannot show. I did not measure it. Browsers already treat wheel, touchstart and touchmove listeners on window, document and body as passive by default, so you mostly meet this when you want to opt in explicitly or when you need to opt out of it (passive: false).

Part 5. Event delegation

Say you have a list with 1,000 rows, each with a delete button. Do you attach 1,000 listeners? You do not need to. A click on a child bubbles up to the list, so one listener on the list can handle all of them. It also works for rows you add later.

JavaScript
const { document } = new JSDOM(`<ul id="list"><li data-id="1">A <button class="del">x</button></li><li data-id="2"><span class="name">B</span></li></ul>`).window;const ul = document.getElementById('list');let handlers = 0;ul.addEventListener('click', e => {  handlers++;  const del = e.target.closest('.del');  if (del && ul.contains(del)) { const li = del.closest('li'); console.log('delete', li.dataset.id); li.remove(); return; }  const li = e.target.closest('li');  if (li) console.log('open', li.dataset.id, 'clicked tag', e.target.tagName);});ul.querySelector('.name').click();ul.querySelector('.del').click();const li3 = document.createElement('li'); li3.dataset.id = '3'; li3.innerHTML = '<b>C</b>'; ul.append(li3);li3.querySelector('b').click();console.log('listeners attached: 1, handler calls:', handlers);
TEXT
open 2 clicked tag SPANdelete 1open 3 clicked tag Blisteners attached: 1, handler calls: 3

Trace:

  1. I clicked the span inside row 2. The click bubbled to the ul. e.target was the SPAN, not the li.
  2. e.target.closest('li') walked up from the span to find the row. That is why closest is the delegation tool: the target can be any child inside the row.
  3. The delete button case first checks closest('.del').
  4. Row 3 did not exist when I attached the listener, and it worked at once.

Points to remember:

  • Always use closest(), not e.target.matches(). A click on the <b> inside a button would otherwise miss.
  • Check ul.contains(found) so a match outside your container does not count.
  • Delegation only works for events that bubble. click, input, keydown and submit do. focus and blur do not (use focusin and focusout), and neither do mouseenter and mouseleave (use mouseover).
  • Do not call stopPropagation() on children carelessly. It breaks delegation (and analytics scripts) higher up.

Part 6. Custom events

You can make your own events to let parts of a page talk without calling each other. Use CustomEvent and put data in detail:

JavaScript
const bus = document.createElement('div');bus.addEventListener('cart:add', e => console.log('cart:add', e.detail, 'bubbles', e.bubbles));document.body.append(bus);document.body.addEventListener('cart:add', e => console.log('body saw it, target', e.target.tagName));bus.dispatchEvent(new CustomEvent('cart:add', { detail: { sku: 'A1', qty: 2 }, bubbles: true }));bus.dispatchEvent(new CustomEvent('cart:add', { detail: { sku: 'A2' } }));
TEXT
cart:add { sku: 'A1', qty: 2 } bubbles truebody saw it, target DIVcart:add { sku: 'A2' } bubbles false
  • A custom event does not bubble unless you say bubbles: true. The second dispatch did not reach body.
  • dispatchEvent is synchronous: every listener runs before the next line of your code. I checked it with a listener object:
JavaScript
const obj = { name: 'obj', handleEvent(e) { console.log('handleEvent on', this.name, e.type); } };bus.addEventListener('ping', obj);console.log('before'); bus.dispatchEvent(new Event('ping')); console.log('after (dispatch is synchronous)');
TEXT
beforehandleEvent on obj pingafter (dispatch is synchronous)
  • A listener can be a function or an object with a handleEvent method. In the object case, this is the object (compare with this in a function listener, which is the element, a topic of Lesson 1).
  • Name your events with a prefix (cart:add) so they do not clash with built-in ones.

Part 7. Form events

Forms have their own events and a built-in checker. I ran a form with a required email and an age with a minimum:

JavaScript
const dom = new JSDOM(`<form id="f"><input name="email" type="email" required><input name="age" type="number" min="18"><button>Send</button></form>`);const { document, Event } = dom.window;const f = document.getElementById('f'), email = f.elements.email, age = f.elements.age;f.addEventListener('submit', e => { e.preventDefault(); console.log('submit handled:', Object.fromEntries(new dom.window.FormData(f))); });email.value = 'a@b.co'; age.value = '20';f.requestSubmit();
TEXT
submit handled: { email: 'a@b.co', age: '20' }

requestSubmit() runs validation and fires the submit event like a real button press would. preventDefault() stops the browser from navigating, so we can handle the data in JavaScript. FormData reads every named field. Note that age is the string '20', not a number. Input values are always strings.

JavaScript
email.addEventListener('input', e => console.log('input event, value =', e.target.value));email.addEventListener('change', e => console.log('change event, value =', e.target.value));email.value = 'typed-by-code'; console.log('(setting .value fires nothing)');email.dispatchEvent(new Event('input', { bubbles: true })); email.dispatchEvent(new Event('change', { bubbles: true }));
TEXT
(setting .value fires nothing)input event, value = typed-by-codechange event, value = typed-by-code
  • Setting .value from code fires no event. That is why changing a field from code does not update a framework's state: the framework listens for input.
  • input fires on every change (each key). change fires when the user commits the value (for a text box, when it loses focus after a change; for a checkbox or select, right away). I dispatched both by hand here, so what I show is that they carry the value, not the timing. The timing is browser behaviour that I did not run.

Validation is built in. In jsdom, an empty required email gave checkValidity() of false with the message Constraints not satisfied; 'nope' set validity.typeMismatch; age 5 set rangeUnderflow; and reportValidity() fired an invalid event on each bad field (it does not bubble, so I listened in the capture phase on the form). Real browsers write their own translated messages, so do not test against that text. Client-side checks are for comfort: the server must check everything again (Browser, Lesson 7).

Part 8. What jsdom does not do

jsdom is excellent for testing DOM logic and events. It is not a browser. Here is what I measured on a 100 by 50 pixel div:

JavaScript
const dom = new JSDOM(`<div id="box" style="width:100px;height:50px">hi</div>`, { pretendToBeVisual: true });const { window } = dom; const box = window.document.getElementById('box');console.log('getBoundingClientRect', JSON.stringify(box.getBoundingClientRect()));console.log('offsetWidth', box.offsetWidth, 'clientHeight', box.clientHeight, 'scrollHeight', box.scrollHeight);console.log('innerWidth', window.innerWidth, 'style.width', box.style.width, 'computed width', window.getComputedStyle(box).width);console.log('IntersectionObserver', typeof window.IntersectionObserver, 'ResizeObserver', typeof window.ResizeObserver, 'matchMedia', typeof window.matchMedia);
TEXT
getBoundingClientRect {"x":0,"y":0,"bottom":0,"height":0,"left":0,"right":0,"top":0,"width":0}offsetWidth 0 clientHeight 0 scrollHeight 0innerWidth 1024 style.width 100px computed width 100pxIntersectionObserver undefined ResizeObserver undefined matchMedia undefined
  • No layout. Every size and position is 0, even for an element with style="width:100px". style.width returns the text you wrote, not a measured value. Code that reads getBoundingClientRect, offsetWidth or scrollHeight gets zeros.
  • No IntersectionObserver, ResizeObserver or matchMedia in this setup (the type was undefined). You must supply fakes in tests.
  • innerWidth is a fixed 1024 number, not a real window.
  • It has no rendering and no painting, so it cannot check that something is visible, covered by another element, or in the right colour. Focus and tab order are only partly modelled.
  • Default actions like following a link are queued and happen a moment after the click (as we saw).
  • MutationObserver and requestAnimationFrame do exist (with pretendToBeVisual: true). I checked that a MutationObserver reported one childList record and that a requestAnimationFrame callback ran.

So use jsdom (or a similar tool) for logic: "clicking this button adds a row". Use a real browser test tool for anything about layout, scrolling, visibility or CSS (Testing, Lesson 2). The DOM rules in this lesson are the same in both.

Try it

  1. Given <ul id="u"><li>a</li><li>b</li></ul>, write code that adds <li>c</li> safely from a variable text that comes from a user.
  2. Predict the order for a click on #inner with listeners on #outer (bubble), #middle (capture) and #inner (bubble). Nesting is outer > middle > inner.
  3. A list has a <button> with a <svg> icon inside each row. A delegated handler uses if (e.target.matches('button')). Clicks on the icon do nothing. Why, and how do you fix it?
  4. Why does button.removeEventListener('click', () => doIt()) not remove anything?
  5. You set input.value = 'x' in code and your framework does not notice. Why, and what do you do?
  6. You test a "sticky header" function that reads getBoundingClientRect().top in jsdom. What goes wrong?

Answers

  1. const li = document.createElement('li'); li.textContent = text; u.append(li); Never put text into innerHTML.
  2. middle (capture) first, then inner, then outer (bubble). The capture phase runs on the way down and only middle has a capture listener. Bubble listeners run at inner (target) and then outer.
  3. The click's target is the svg (or its path), not the button, so matches('button') is false. Use e.target.closest('button') and check that it exists. This is the same reason my example used closest.
  4. The arrow function written in removeEventListener is a new function object, not the one registered. Keep a reference (const onClick = () => doIt();) and pass it to both calls, or use { signal } and call abort().
  5. Setting .value from code fires no input event, and frameworks listen for it. After setting the value, dispatch new Event('input', { bubbles: true }). (Some frameworks need the value set through the native setter first, which is a React detail I did not test.)
  6. jsdom has no layout, so every rect is zeros (as shown). The function always sees top as 0. Mock getBoundingClientRect in the test, or test in a real browser.

Interview questions

What is the difference between childNodes and children, and between a NodeList and an HTMLCollection? (Trap)

childNodes includes every node: elements, text (including whitespace between tags) and comments. children has only elements. querySelectorAll returns a static NodeList, a snapshot. getElementsByTagName returns a live HTMLCollection. I proved it: after appending one li, the live one said 4 and the static one said 3. A NodeList has forEach but no map, so I use Array.from or spread.

textContent or innerHTML: which and why? (Must know)

For any text that might come from a user or an API I use textContent, because it treats the string as plain text. I showed that innerHTML with <img src=x onerror=...> produced a real img with a real onerror attribute, which in a browser would run code (XSS). I use innerHTML only for markup I wrote, or after sanitising. Also, innerHTML += rebuilds all children and loses their listeners, so I use append or insertAdjacentHTML.

Explain the three phases of event propagation. (Must know)

Capturing goes from the window down to the target's parent. Then the target phase runs on the target. Then bubbling goes back up. By default a listener runs in the bubbling phase; with capture: true it runs while going down. In my traced example with three nested elements, the order was outer capture, middle capture, inner (capture then bubble), middle bubble, outer bubble. event.target stayed inner the whole time, and currentTarget changed at each step.

What is the difference between stopPropagation() and preventDefault()? (Trap)

stopPropagation() stops the event from travelling to other elements on its path. preventDefault() stops the browser's default action, such as navigating a link or submitting a form, but the event still travels. They are independent. I showed that a stop on middle left outer silent, and that a prevented link did not change the hash while a plain link did. Also, stopPropagation() does not stop other listeners on the same element; stopImmediatePropagation() does (in my test, neither the second listener nor body ran). And preventDefault() only works if the event is cancelable.

What is event delegation? What are its limits? (Must know)

I attach one listener to a parent and use event.target.closest(selector) to find which child was meant. It uses less memory than many listeners and it works for children added later. In my demo, one listener handled rows that existed at the start and a row I added afterwards. Limits: it works only for events that bubble (focus, blur, mouseenter do not; I use focusin, focusout), and a child that calls stopPropagation() breaks it. I always use closest, because the target may be an icon inside the button.

What does passive: true do? (Senior signal)

It is a promise that the handler will not call preventDefault(). For touch and wheel events, the browser can then start scrolling without waiting for my handler, so scrolling stays smooth. If I call preventDefault() anyway, it is ignored; I confirmed in jsdom that defaultPrevented stayed false. The speed gain is a browser feature that I did not measure here. I only leave it off when I truly need to cancel the scroll.

Why does removeEventListener('click', () => go()) not work? (Trap)

removeEventListener matches the exact function reference (and the same capture flag). A new arrow function is a different object. I found that adding the same function twice registers it once, but two separate arrow functions both run. I keep the handler in a variable, or I pass a signal to addEventListener and call abort() to remove many listeners at once.

How do custom events work, and are they synchronous? (Senior signal)

I create a CustomEvent with a detail payload and call dispatchEvent on an element. It does not bubble unless I set bubbles: true; in my run the second dispatch did not reach body. dispatchEvent is synchronous: all listeners finish before the next line runs, which I showed with before and after logs. It is useful for decoupling components, but a prefix on the event name avoids clashes.

What is the difference between the input and change events? Why does setting .value not trigger them? (Senior signal)

input fires on every edit. change fires when the user commits the value (for a text field, on blur after a change). Changing .value from code fires neither, which I confirmed. A framework that listens for input will not notice, so I dispatch new Event('input', { bubbles: true }) after the change. I did not run the timing of change in a real browser, only the event objects in jsdom.

What can't you test with jsdom? (Senior signal)

Anything that needs layout or rendering. In my run, a 100 by 50 pixel div reported width and height 0 from getBoundingClientRect and offsetWidth, and IntersectionObserver, ResizeObserver and matchMedia were missing. So visibility, scrolling, sticky positions, CSS and real focus behaviour need a real browser. jsdom is good for logic: "after this click, this row exists".

Key takeaways

  • The DOM is a tree of objects. children is elements only; childNodes includes text and comments. querySelectorAll is a snapshot; getElementsBy... is live.
  • Use textContent for untrusted text. innerHTML parses HTML and can run code (XSS); innerHTML += also destroys listeners.
  • An event goes capture (down), target, bubble (up). target is fixed; currentTarget changes.
  • stopPropagation stops the trip; preventDefault stops the default action. They are different and independent.
  • Delegation: one listener on the parent, find the child with closest. It only works for events that bubble.
  • Pass the same function reference to remove a listener, or use { signal } or { once: true }.
  • Custom events need bubbles: true to travel and are dispatched synchronously.
  • Setting .value from code does not fire input. Browsers validate forms, but the server must too.
  • jsdom has no layout: all sizes are 0. Test logic there, and layout in a real browser.

What's next

Lesson 6: Errors, Proxy, Reflect, Copying and Immutability goes back to the language. You will learn how errors travel, how to intercept operations on an object, and how to copy and change data without surprises.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Related series