
Learn modern DOM event handling, including propagation, delegation, listener cleanup, custom events, forms, state management, efficient mutations, and observers to build responsive, performant pages without leaks.
Martin Ferret
November 10, 2026
Selecting and traversing the DOM is only half the job. The other half is reacting to events without leaking listeners, and mutating the page without killing performance. Essential for exams, interviews, and real-world debugging.
Capture → Target → Bubble{ capture: true }el.addEventListener('focus', fn, { capture: true });e.target // deepest element clickede.currentTarget // the element listeninge.preventDefault(); // don't submit, don't follow the linke.stopPropagation(); // ancestors never see ite.stopImmediatePropagation();
Remember: preventDefault cancels the action, stopPropagation cancels the journey. They are unrelated.Note: stopPropagation does not stop the other listeners attached to the same element → that's stopImmediatePropagation.
dialog.addEventListener('close', cleanup, { once: true });preventDefault, keeps scrolling smooth
el.addEventListener('touchmove', fn, { passive: true });document.addEventListener('error', fn, { capture: true }); // failed <img> loadsAbortControllerel.addEventListener('click', fn, { signal: controller.signal });
Note: removeEventListener needs the exact same function reference → arrow functions defined inline can never be removed. Use { signal } instead.// Legacy: one listener per row, dead on re-render, leaks on teardown
document.querySelectorAll('li').forEach(li => {
li.addEventListener('click', () => {
li.parentNode.removeChild(li);
});
});
// Modern: one listener, works on future rows, cancellable
const controller = new AbortController();
list.addEventListener('click', (e) => {
const row = e.target.closest('li[data-id]');
if (!row) return;
console.log(row.dataset.id);
row.remove();
}, { signal: controller.signal });
// later, on teardown:
controller.abort(); // removes every listener registered with this signal
closest → finds the row even if the click hit a nested <span>dataset.id → reads data-id without getAttributeabort() → one call to clean up, whatever the number of listenersnew CustomEvent('item:selected', { detail: { id }, bubbles: true });el.dispatchEvent(evt);list.addEventListener('item:selected', e => load(e.detail.id));
Note: custom events do not bubble by default → pass bubbles: true explicitly.const data = new FormData(form);const body = Object.fromEntries(new FormData(form));multiple)
data.getAll('tags');nameform.elements.email.value;if (!form.checkValidity()) form.reportValidity();
Note: FormData only collects controls that have a name and are not disabled.Note: submit bubbles, focus and blur do not → use focusin / focusout when delegating.
el.classList.toggle('active', isActive);btn.toggleAttribute('disabled', isLoading);data-* attributes as camelCase properties
el.dataset.userId; // <div data-user-id="42">el.style.setProperty('--progress', '40%');getComputedStyle(el).getPropertyValue('--progress');
Note: dataset.userId reads data-user-id, and setting it writes the attribute back to the HTML.list.replaceChildren(...rows); list.replaceChildren();const f = new DocumentFragment(); f.append(...items); list.append(f);list.insertAdjacentHTML('beforeend', '<li>New</li>');const node = tpl.content.cloneNode(true);onScroll = () => requestAnimationFrame(() => header.classList.toggle('stuck', scrollY > 0));
Positions for insertAdjacent*: beforebegin | afterbegin … element … beforeend | afterendnew IntersectionObserver(entries => {}).observe(el);resize listener needed
new ResizeObserver(entries => {}).observe(box);new MutationObserver(records => {}).observe(el, { childList: true });
All three expose disconnect() → always call it on teardown.removeEventListener → never removed, the reference differs{ signal } or { once: true }e.target is the deepest node clicked → always go through closest when delegatingpreventDefault inside a passive listener → ignored, with a console warninginnerHTML += → re-parses everything, destroys existing listeners and stateDocumentFragment or replaceChildrene.target = what was clicked, e.currentTarget = what is listeningsignal or oncecapture → target → bubble → the path of every event{ signal } → cancel every listener with one abort(){ once: true } → self-removing listener{ passive: true } → scroll stays smoothclosest in the handler → the delegation keyCustomEvent + bubbles: true → child to parent messagingFormData + Object.fromEntries → form to object in one linedataset.userId → reads and writes data-user-idclassList.toggle(name, bool) → state driven by a booleanreplaceChildren() → clear a container, safest resetDocumentFragment → batch inserts, one single reflow
Which one do you reach for first: delegation, or a listener per element? Drop it below to make it stick.Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.

DOM Events & Modern Patterns Cheat Sheet
Learn modern DOM event handling, including propagation, delegation, listener cleanup, custom events, forms, state management, efficient mutations, and observers to build responsive, performant pages without leaks.
Martin Ferret
Nov 10, 2026

this in JavaScript: Binding Rules and Arrow Functions
JavaScript’s this depends on the call site, with explicit binding and new taking precedence; arrow functions inherit this, making them useful for callbacks but unsuitable as object methods.
Martin Ferret
Nov 10, 2026

Which Fetch? A One Page Reference for Nuxt Data Fetching
Choose among Nuxt’s $fetch, useFetch, useAsyncData, and lazy variants based on rendering, interaction, and data needs, while avoiding common pitfalls with keys, caching, SSR, and payloads.
Reza Baar
Nov 10, 2026