DOM Events & Modern Patterns Cheat Sheet

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

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.

Core concepts

  • Every event travels the tree: capture down, hit the target, bubble up
  • Bubbling is a feature → listen once on a parent instead of N times on the children
  • Every listener you add is a leak until you remove it
  • State belongs in attributes and classes, not in scattered inline styles
  • Batch your writes: the DOM is fast, reflowing it 100 times is not Capture → Target → Bubble

Event propagation

  • Capture phase → Root to target, opt in with { capture: true }
    • el.addEventListener('focus', fn, { capture: true });
  • Target phase → The element that actually fired the event
    • e.target // deepest element clicked
  • Bubble phase → Target back to root, the default
    • e.currentTarget // the element listening
  • preventDefault() → Cancel the browser's default action
    • e.preventDefault(); // don't submit, don't follow the link
  • stopPropagation() → Stop the event from travelling further up or down
    • e.stopPropagation(); // ancestors never see it
  • stopImmediatePropagation() → Also skip the remaining listeners on the same element
    • e.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.

Listener options

  • { once: true } → Auto-removed after the first call
    • dialog.addEventListener('close', cleanup, { once: true });
  • { passive: true } → Promise not to preventDefault, keeps scrolling smooth
    • el.addEventListener('touchmove', fn, { passive: true });
  • { capture: true } → Handle on the way down, the only way to catch non-bubbling events on a parent
    • document.addEventListener('error', fn, { capture: true }); // failed <img> loads
  • { signal } → Tie the listener to an AbortController
    • el.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.

Delegation & cleanup

// 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>
  • Works for rows added later → no need to re-bind after a render
  • dataset.id → reads data-id without getAttribute
  • abort() → one call to clean up, whatever the number of listeners

Custom events

  • new CustomEvent(type, { detail, bubbles }) → Carry data, opt in to bubbling
    • new CustomEvent('item:selected', { detail: { id }, bubbles: true });
  • dispatchEvent(event) → Fire it from any element
    • el.dispatchEvent(evt);
  • e.detail → Read the payload in the handler
    • list.addEventListener('item:selected', e => load(e.detail.id)); Note: custom events do not bubble by default → pass bubbles: true explicitly.

Forms

  • new FormData(form) → All fields at once, no manual reads
    • const data = new FormData(form);
  • Object.fromEntries(data) → FormData to a plain object, keeps the last value of repeated names
    • const body = Object.fromEntries(new FormData(form));
  • data.getAll(name) → Multi-value fields (checkboxes, multiple)
    • data.getAll('tags');
  • form.elements.name → Direct access to a control by its name
    • form.elements.email.value;
  • checkValidity() / reportValidity() → Native validation, free UX
    • 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.

State: attributes, data & classes

  • classList.toggle(name, force) → Force a state from a boolean
    • el.classList.toggle('active', isActive);
  • toggleAttribute(name, force) → Boolean attributes without empty-string hacks
    • btn.toggleAttribute('disabled', isLoading);
  • dataset → data-* attributes as camelCase properties
    • el.dataset.userId; // <div data-user-id="42">
  • style.setProperty(name, value) → The only way to set a CSS custom property
    • el.style.setProperty('--progress', '40%');
  • getComputedStyle(el) → The resolved value, not the inline one
    • getComputedStyle(el).getPropertyValue('--progress'); Note: dataset.userId reads data-user-id, and setting it writes the attribute back to the HTML.

Mutating efficiently

  • replaceChildren(...) → Replace all children, or clear with no args
    • list.replaceChildren(...rows); list.replaceChildren();
  • DocumentFragment → Batch many nodes, a single reflow on insert
    • const f = new DocumentFragment(); f.append(...items); list.append(f);
  • insertAdjacentHTML(position, html) → Insert HTML without touching existing nodes
    • list.insertAdjacentHTML('beforeend', '<li>New</li>');
  • <template> → Inert markup, cloned on demand
    • const node = tpl.content.cloneNode(true);
  • requestAnimationFrame(fn) → Batch writes just before the next paint, never write in a scroll handler
    • onScroll = () => requestAnimationFrame(() => header.classList.toggle('stuck', scrollY > 0)); Positions for insertAdjacent*: beforebegin | afterbegin … element … beforeend | afterend

Observers

  • IntersectionObserver → Visible in the viewport, for lazy load and infinite scroll
    • new IntersectionObserver(entries => {}).observe(el);
  • ResizeObserver → Element size changed, no resize listener needed
    • new ResizeObserver(entries => {}).observe(box);
  • MutationObserver → The DOM tree changed, last resort, use sparingly
    • new MutationObserver(records => {}).observe(el, { childList: true }); All three expose disconnect() → always call it on teardown.

Common pitfalls

  • Inline arrow function + removeEventListener → never removed, the reference differs
  • Listeners left on removed elements → memory leaks. Use { signal } or { once: true }
  • Re-binding listeners after every render → delegate on a stable parent instead
  • e.target is the deepest node clicked → always go through closest when delegating
  • preventDefault inside a passive listener → ignored, with a console warning
  • innerHTML += → re-parses everything, destroys existing listeners and state
  • Read / write / read / write in a loop → layout thrashing. Read all, then write all
  • Inserting nodes one by one → use a DocumentFragment or replaceChildren

Ultra-short mental model

  • One listener on the parent beats N listeners on the children
  • e.target = what was clicked, e.currentTarget = what is listening
  • Every listener needs an exit plan → signal or once
  • Batch reads, then batch writes, then let the browser paint

One-line memory map

  • capture → target → bubble → the path of every event
  • { signal } → cancel every listener with one abort()
  • { once: true } → self-removing listener
  • { passive: true } → scroll stays smooth
  • closest in the handler → the delegation key
  • CustomEvent + bubbles: true → child to parent messaging
  • FormData + Object.fromEntries → form to object in one line
  • dataset.userId → reads and writes data-user-id
  • classList.toggle(name, bool) → state driven by a boolean
  • replaceChildren() → clear a container, safest reset
  • DocumentFragment → 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.

More certificates.dev articles

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.