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:
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
- Capture — the event travels down from
windowto the target. - Target — it reaches the element that was interacted with.
- 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
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:
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:
Closing a menu when clicking outside
This works because clicks anywhere on the page bubble up to document.