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.
On this page
- Words used in this lesson
- Part 1. The DOM is a tree of objects
- Part 2. Creating and changing nodes
- Part 3. textContent vs innerHTML
- Part 4. Event listeners: capture, target, bubble
- Part 5. Event delegation
- Part 6. Custom events
- Part 7. Form events
- Part 8. What jsdom does not do
- Try it
- Interview questions
- Key takeaways
- 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.doneor#list > li.querySelectortakes 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:
<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:
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);1 UL 3 31:LI 1:LI 1:LIone donenodeType1means "element". Text nodes are3and comments are8.childNodeslists all child nodes.childrenlists 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,childNodeswould also contain text nodes made of whitespace, and the two counts would differ. This surprises many people.- Walk the tree with
parentNode,firstElementChild,nextElementSiblingand friends. The...Element...versions skip text nodes.
Finding nodes
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'));two 3nullapp true| Method | Returns | Notes |
|---|---|---|
querySelector(sel) | first match or null | takes any CSS selector |
querySelectorAll(sel) | a static NodeList | a snapshot; later changes do not show |
getElementById(id) | the element or null | fast, no # in the argument |
getElementsByTagName(name) / getElementsByClassName(name) | a live HTMLCollection | updates itself when the page changes |
el.closest(sel) | the nearest ancestor (or itself) that matches, or null | the key tool for delegation (Part 5) |
el.matches(sel) | true or false | does 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:
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);live 4 static 3[object NodeList] undefined function 3After 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
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);<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:
createElement('li')made a node that is not in the page yet. It lives only in memory.textContent,classList,setAttributeanddatasetchanged it.dataset.kindis the same as the attributedata-kind.appendput it into the list.prependadded an empty one at the front.cloneNode(true)copied the node and its children (truemeans "deep"). The copy kept the classes and data attributes.insertAdjacentHTMLparsed an HTML string and inserted it at a position (beforeendmeans "just inside the end").
Now change and remove:
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);<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.
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'));textContent -> <img src=x onerror="alert(1)"> & <b>bold</b>innerHTML -> <img src="x" onerror="alert(1)"> & <b>bold</b> | children: IMG,BtextContent read: " & bold"onerror attr present: truetextContent | innerHTML | |
|---|---|---|
| The string is treated as | plain text | HTML 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 gives | all the text, with tags removed | the HTML markup |
| Safe with user input? | yes | no |
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:
- Use
textContent(orcreateElementandappend) for any text that came from a user, a URL, a database or an API. - Use
innerHTMLonly for markup you wrote yourself, or after cleaning it with a sanitiser library. - Escaping by hand is a last resort. A small escape function turns the same string into harmless text:
const esc = s => s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));console.log(esc(evil));<img src=x onerror="alert(1)"> & <b>bold</b>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:
| Field | Meaning |
|---|---|
type | 'click', 'input', 'submit', ... |
target | the element where the event started |
currentTarget | the element whose listener is running now |
eventPhase | 1 capturing, 2 at target, 3 bubbling |
bubbles | does it go back up after the target? |
cancelable | can preventDefault() stop its default action? |
defaultPrevented | has preventDefault() been called? |
The three phases, traced
An event does not just run on the clicked element. It makes a round trip:
- Capturing phase. From
windowdown to the parent of the target. - Target phase. On the target itself.
- Bubbling phase. From the target back up to
window(only if the eventbubbles;clickdoes).
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:
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();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=3Read the six lines as a path:
outer(capture) -> middle(capture) -> inner(target: capture, then bubble) -> middle(bubble) -> outer(bubble)targetisinneron every line. It never changes during the trip.currentTargetchanges at every step: it is where the listener is attached.- At the target (
phase=2), both listeners oninnerrun. 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() | |
|---|---|---|
| Stops | the event moving to the next element on its path | the browser's default action (follow a link, submit a form, type a character) |
| Other listeners on the same element | still run | still run |
Needs cancelable | no | yes |
First, stopPropagation. I added a stopper on middle (bubble phase) and then, in a second try, on outer in the capture phase:
const stop = e => { console.log(' middle stops it'); e.stopPropagation(); };middle.addEventListener('click', stop);inner.click();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 itThe 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.
const capStop = e => { console.log(' outer capture stops it'); e.stopPropagation(); };outer.addEventListener('click', capStop, true);inner.click();stopPropagation at outer capture: capture outer target=inner currentTarget=outer phase=1 outer capture stops itStopping 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:
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();firstNeither 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:
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);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:
dispatchEvent returned false | defaultPrevented true(From a cancelable click event with a handler that called preventDefault().)
Listener options
| Option | Effect |
|---|---|
capture: true | listen in the capturing phase |
once: true | remove the listener after the first call |
passive: true | promise to never call preventDefault() |
signal: abortSignal | remove the listener when the signal aborts |
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)');once -> 1signal -> 1same function added twice runs 1 time(s)two different arrow functions run 2 time(s)onceran the handler one time out of two dispatches.signalgives a neat clean-up: oneabort()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:
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);passive: true falsenormal: false trueWith 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.
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);open 2 clicked tag SPANdelete 1open 3 clicked tag Blisteners attached: 1, handler calls: 3Trace:
- I clicked the
spaninside row 2. The click bubbled to theul.e.targetwas theSPAN, not theli. e.target.closest('li')walked up from the span to find the row. That is whyclosestis the delegation tool: the target can be any child inside the row.- The delete button case first checks
closest('.del'). - Row 3 did not exist when I attached the listener, and it worked at once.
Points to remember:
- Always use
closest(), note.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,keydownandsubmitdo.focusandblurdo not (usefocusinandfocusout), and neither domouseenterandmouseleave(usemouseover). - 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:
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' } }));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 reachbody. dispatchEventis synchronous: every listener runs before the next line of your code. I checked it with a listener object:
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)');beforehandleEvent on obj pingafter (dispatch is synchronous)- A listener can be a function or an object with a
handleEventmethod. In the object case,thisis the object (compare withthisin 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:
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();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.
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 }));(setting .value fires nothing)input event, value = typed-by-codechange event, value = typed-by-code- Setting
.valuefrom code fires no event. That is why changing a field from code does not update a framework's state: the framework listens forinput. inputfires on every change (each key).changefires 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:
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);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 withstyle="width:100px".style.widthreturns the text you wrote, not a measured value. Code that readsgetBoundingClientRect,offsetWidthorscrollHeightgets zeros. - No
IntersectionObserver,ResizeObserverormatchMediain this setup (the type wasundefined). You must supply fakes in tests. innerWidthis a fixed1024number, 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).
MutationObserverandrequestAnimationFramedo exist (withpretendToBeVisual: true). I checked that aMutationObserverreported onechildListrecord and that arequestAnimationFramecallback 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
- Given
<ul id="u"><li>a</li><li>b</li></ul>, write code that adds<li>c</li>safely from a variabletextthat comes from a user. - Predict the order for a click on
#innerwith listeners on#outer(bubble),#middle(capture) and#inner(bubble). Nesting isouter > middle > inner. - A list has a
<button>with a<svg>icon inside each row. A delegated handler usesif (e.target.matches('button')). Clicks on the icon do nothing. Why, and how do you fix it? - Why does
button.removeEventListener('click', () => doIt())not remove anything? - You set
input.value = 'x'in code and your framework does not notice. Why, and what do you do? - You test a "sticky header" function that reads
getBoundingClientRect().topin jsdom. What goes wrong?
Answers
const li = document.createElement('li'); li.textContent = text; u.append(li);Never puttextintoinnerHTML.middle(capture) first, theninner, thenouter(bubble). The capture phase runs on the way down and onlymiddlehas a capture listener. Bubble listeners run atinner(target) and thenouter.- The click's
targetis thesvg(or itspath), not the button, somatches('button')isfalse. Usee.target.closest('button')and check that it exists. This is the same reason my example usedclosest. - The arrow function written in
removeEventListeneris a new function object, not the one registered. Keep a reference (const onClick = () => doIt();) and pass it to both calls, or use{ signal }and callabort(). - Setting
.valuefrom code fires noinputevent, and frameworks listen for it. After setting the value, dispatchnew Event('input', { bubbles: true }). (Some frameworks need the value set through the native setter first, which is a React detail I did not test.) - jsdom has no layout, so every rect is zeros (as shown). The function always sees
topas0. MockgetBoundingClientRectin 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.
childrenis elements only;childNodesincludes text and comments.querySelectorAllis a snapshot;getElementsBy...is live. - Use
textContentfor untrusted text.innerHTMLparses HTML and can run code (XSS);innerHTML +=also destroys listeners. - An event goes capture (down), target, bubble (up).
targetis fixed;currentTargetchanges. stopPropagationstops the trip;preventDefaultstops 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: trueto travel and are dispatched synchronously. - Setting
.valuefrom code does not fireinput. 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
- Forms, Events and Controlled Inputs · React in Depth
- 1:1s, Feedback, Delegation and Conflict · Team Lead Mindset
Related series