addEventListener
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
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.
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
// 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:closestcan walk out of the list if the markup nests oddly.
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
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);
});
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.
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.
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.
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.
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));