webdev.complete
🌳 The DOM & Events
JavaScript
Lesson 35 of 117
30 min

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

js
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.
Don't use 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:

js
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:

js
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:

js
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.
js
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:

js
// 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 call
The AbortSignal trick is amazing
Component teardown is usually a list of removeEventListener 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:

js
// 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.

const grid = document.getElementById("grid");
const addBtn = document.getElementById("add");
let nextId = 0;

function makeBox() {
  const div = document.createElement("div");
  div.className = "box";
  div.dataset.id = ++nextId;
  div.dataset.count = "0";
  div.textContent = "Box " + nextId + " (0)";
  grid.appendChild(div);
}

// Seed three boxes
makeBox(); makeBox(); makeBox();

// Single delegated listener
grid.addEventListener("click", e => {
  const box = e.target.closest(".box");
  if (!box) return;

  const next = Number(box.dataset.count) + 1;
  box.dataset.count = next;
  box.textContent = "Box " + box.dataset.id + " (" + next + ")";
  console.log("clicked box", box.dataset.id, "count:", next);
});

addBtn.addEventListener("click", makeBox);

// Bonus: keyboard shortcut to reset, with AbortController cleanup
const controller = new AbortController();
document.addEventListener("keydown", e => {
  if (e.key === "r") {
    document.querySelectorAll(".box").forEach(b => {
      b.dataset.count = "0";
      b.textContent = "Box " + b.dataset.id + " (0)";
    });
    console.log("reset");
  }
}, { signal: controller.signal });

Quiz

Quiz1 / 3

What does event delegation let you do?

Recap

  • Always use addEventListener. Avoid el.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 single AbortController to detach many listeners at once.
  • Dispatch your own CustomEvents to decouple modules.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.