Events & Delegation
addEventListener, bubbling, custom events, signal: AbortSignal.
The web is event-driven. Clicks, keystrokes, scrolls, network responses: everything that happens after the page loads goes through the event system. Once you understand the bubbling model and the delegation pattern, you can wire up almost any UI with very little code.
addEventListener: the only API you need
const btn = document.querySelector("#save");
btn.addEventListener("click", event => {
console.log("clicked!", event);
});
// To remove a listener, you need the same function reference
function onClick(e) { /* ... */ }
btn.addEventListener("click", onClick);
btn.removeEventListener("click", onClick);
// Anonymous arrows can't be removed individually - keep a reference if you'll detach.el.onclick = fn in new code. It only allows one handler per event. Always use addEventListener.The event object
Every handler receives an event object with details about what happened. The most useful fields:
btn.addEventListener("click", e => {
e.target // the element that was actually clicked
e.currentTarget // the element the listener is attached to
e.type // "click"
e.preventDefault() // stop the default browser action
e.stopPropagation()// stop the event from bubbling further
});
input.addEventListener("keydown", e => {
e.key // "Enter", "a", "ArrowUp", etc.
e.code // physical key (e.g. "KeyA", ignores layout)
e.shiftKey // boolean
e.metaKey // command on mac, win on windows
});Bubbling and capturing
When you click a button inside a div inside the body, the click event fires on all three. By default it goes "down" through ancestors (capture phase), hits the target, then bubbles back up (bubble phase). Most listeners fire on the way up:
document.body.addEventListener("click", () => console.log("body"));
container.addEventListener("click", () => console.log("container"));
button.addEventListener("click", () => console.log("button"));
// Clicking the button logs:
// button
// container
// body
// Want capture instead?
document.body.addEventListener("click", handler, { capture: true });Event delegation: the killer pattern
Imagine a list of 500 items, each with a delete button. Attaching 500 listeners is wasteful and breaks for items added later. Instead, attach ONE listener to the parent and inspect the target:
document.querySelector("#list").addEventListener("click", e => {
// Was it a delete button? closest handles nested icons etc.
const removeBtn = e.target.closest(".delete");
if (!removeBtn) return;
const item = removeBtn.closest("li");
item?.remove();
});One listener handles every item, current and future. This is how every well-built web app handles lists.
preventDefault vs stopPropagation
preventDefault()tells the browser not to do its default thing for this event (e.g., submit a form, follow a link).stopPropagation()tells the event not to keep bubbling. Other listeners further up never fire.
form.addEventListener("submit", e => {
e.preventDefault(); // don't actually reload the page
// ...validate and fetch...
});
modalCloseBtn.addEventListener("click", e => {
e.stopPropagation(); // don't let the click reach the background
});Listener options: once, passive, signal
The third argument to addEventListener can be an options object with handy flags:
// 1. Fire once, then auto-remove
btn.addEventListener("click", greet, { once: true });
// 2. Promise the browser you won't preventDefault.
// Improves scroll performance dramatically on touch.
window.addEventListener("scroll", onScroll, { passive: true });
// 3. Detach a whole bunch of listeners at once with AbortController
const controller = new AbortController();
window.addEventListener("resize", onResize, { signal: controller.signal });
btn.addEventListener ("click", onClick, { signal: controller.signal });
// Later:
controller.abort(); // both listeners removed in one callremoveEventListener calls. With a shared AbortController, you just call controller.abort() and they all detach at once. Same pattern works for fetch too.Custom events
You can dispatch your own events to communicate between unconnected bits of code:
// Listen
document.addEventListener("cart:updated", e => {
console.log("new total:", e.detail.total);
});
// Dispatch
document.dispatchEvent(new CustomEvent("cart:updated", {
detail: { total: 42.0, items: 3 },
}));Try it: delegated click counter
Click any box. The parent uses delegation, so adding more boxes on the fly "just works." Add some, click them, watch the counts climb.
Quiz
What does event delegation let you do?
Recap
- Always use
addEventListener. Avoidel.onclick = .... - Events bubble from target up to root. Listen high, use
.closest, delegate. e.preventDefault()blocks the browser's default.e.stopPropagation()blocks bubbling. They do different things.{ once, passive, signal }are first-class options. Use a singleAbortControllerto detach many listeners at once.- Dispatch your own
CustomEvents to decouple modules.