Event Bubbling & Delegation

When you click a button, you're also clicking the list item it's in, the list, the page body and the document. Events travel through all of those elements — a process called propagation. Understanding it unlocks event delegation: one listener on a parent that handles events for any number of children, including ones added later.

Bubbling

After an event fires on the element you interacted with (the target), it bubbles up through each ancestor, triggering their listeners for the same event type too:

Try it yourself
Result

Click "inner" and all three listeners run, innermost first. event.target stays the element you actually clicked, while event.currentTarget is the element whose listener is currently running.

The three phases

  1. Capture — the event travels down from window to the target.
  2. Target — it reaches the element that was interacted with.
  3. Bubble — it travels back up to window.

Listeners run in the bubble phase by default. Passing { capture: true } to addEventListener makes a listener run on the way down instead — rarely needed, but useful for intercepting events before children see them. A few events, such as focus, blur, mouseenter and mouseleave, don't bubble (focusin and focusout are bubbling alternatives).

Stopping propagation

Try it yourself
Result

stopPropagation() stops the event from reaching ancestors. Use it sparingly: other code (analytics, "click outside to close" menus) may rely on seeing events bubble. Often, checking event.target in the parent handler is a better solution. Don't confuse it with preventDefault(), which cancels the browser's default action but lets the event keep bubbling.

Event delegation

Instead of adding a listener to every item, add one to their common parent and use event.target.closest() to find which item was clicked:

Try it yourself
Result

Click a task to mark it done, ✕ to delete, and add new tasks — they work immediately, even though no listener was ever attached to them. Benefits of delegation:

  • Dynamic content just works — items added later are handled automatically.
  • Fewer listeners — one instead of hundreds, which saves memory on long lists.
  • Simpler cleanup — removing items never leaves orphaned listeners behind.

Why closest() and not target?

event.target is the deepest element clicked. If a button contains an icon <span>, clicking the icon makes the span the target, and a check like event.target.matches(".remove") fails. closest(".remove") walks up from the target and finds the button either way. Also check that the found element is inside your container, using container.contains(el), when nested lists could cause confusion.

Delegation with data attributes

Store what each button should do in a data-* attribute, and route actions from one listener:

Try it yourself
Result

Closing a menu when clicking outside

Try it yourself
Result

This works because clicks anywhere on the page bubble up to document.