Events

Respond to what people do, with one listener instead of a hundred.

18of 32
≈ 13Min read
9Parts

addEventListener

and its options
el.addEventListener("click", onClick);

el.addEventListener("click", onClick, { once: true });     // removes itself
el.addEventListener("scroll", onScroll, { passive: true }); // promises not to preventDefault
el.addEventListener("click", onClick, { capture: true });   // on the way DOWN
el.addEventListener("click", onClick, { signal: ac.signal });// removable in bulk

el.removeEventListener("click", onClick);   // the SAME function reference
An inline arrow can never be removed

el.addEventListener("click", () => …) creates a new function every call, so removeEventListener has nothing to match. Keep a reference, or use { signal }.

The event object

e.target is why delegation works and also why a naive handler breaks: clicking the icon inside a button gives you the icon. closest() fixes it.

target versus currentTarget
list.addEventListener("click", (e) => {
  e.target;          // the deepest element actually clicked — maybe a <span>
  e.currentTarget;   // the element the listener is on — always the list

  e.preventDefault();    // stop the default action: navigating, submitting
  e.stopPropagation();   // stop it reaching ancestors — use sparingly

  e.key; e.code;                 // keyboard
  e.clientX; e.clientY;          // pointer, relative to the viewport
  e.metaKey; e.ctrlKey; e.shiftKey;
});

Bubbling

the three phases
// capture:  window → document → … → the target
// target:   the element itself
// bubble:   the target → … → document → window   (the default)

// most events bubble. These do not: focus, blur, load, error, mouseenter, mouseleave
// use focusin / focusout / mouseover / mouseout when you need a bubbling version

Delegation

  • One listener instead of one per row.
  • It keeps working for rows added later — no re-binding after a re-render.
  • The list.contains(btn) guard matters: closest can walk out of the list if the markup nests oddly.
one listener for a list that changes
list.addEventListener("click", (e) => {
  const btn = e.target.closest("[data-action]");
  if (!btn || !list.contains(btn)) return;

  const { action, id } = btn.dataset;
  if (action === "open") open(id);
  if (action === "delete") remove(id);
});

preventDefault

what the default actually is
form.addEventListener("submit", (e) => {
  e.preventDefault();        // stop the page reloading
  send(new FormData(form));
});

link.addEventListener("click", (e) => {
  if (e.metaKey || e.ctrlKey || e.button !== 0) return;  // let "open in new tab" work
  e.preventDefault();
  navigate(link.href);
});
A passive listener cannot preventDefault

Scroll and touch listeners are passive by default on the document, because the browser must know immediately whether it may scroll. Calling preventDefault there is ignored, with a console warning.

Keyboard

e.key is the character or a name like "Enter", "Escape", "ArrowDown", " " for space. e.code is the physical key regardless of layout — right for games, wrong for text. keyCode is deprecated.

key, not keyCode
input.addEventListener("keydown", (e) => {
  if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
  if (e.key === "Escape") close();
  if (e.key === "ArrowDown") move(1);
});

Cleaning up

A listener keeps its element and everything the handler closes over alive. In a long-lived page, forgetting to remove them is the ordinary way a memory leak happens.

AbortController removes a whole group at once
const ac = new AbortController();

el.addEventListener("pointermove", onMove, { signal: ac.signal });
window.addEventListener("resize", onResize, { signal: ac.signal });
document.addEventListener("keydown", onKey, { signal: ac.signal });

// later — all three, in one line
ac.abort();

Custom events

Namespacing the name — dv:saved — keeps it from colliding with a built-in or with another library.

your own, with data
el.dispatchEvent(new CustomEvent("dv:saved", {
  detail: { id: 7 },
  bubbles: true,
}));

document.addEventListener("dv:saved", (e) => console.log(e.detail.id));

Throttling what fires constantly

For anything visual, requestAnimationFrame is the right rate: exactly once per frame, and never in a background tab. For a search box, debounce — you want the last value, not sixty of them.

scroll, resize, pointermove and input
const debounce = (fn, ms) => {
  let t;
  return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
};

const rafThrottle = (fn) => {
  let queued = false;
  return (...a) => {
    if (queued) return;
    queued = true;
    requestAnimationFrame(() => { queued = false; fn(...a); });
  };
};

window.addEventListener("scroll", rafThrottle(update), { passive: true });
search.addEventListener("input", debounce(query, 250));

Practice