Events — Bubbling, Delegation and Cleanup
IntermediateEvents travel down to the target and back up again. Bubbling is what makes delegation possible, and delegation is what makes one listener handle a thousand rows.
Overview
An event fires on an element and then travels: first down from the document to the target (capture), then back up (bubble). Almost all handlers listen during bubbling, which means a listener on a container sees events from every descendant. That is event delegation, and it is why a table with a thousand rows needs one click listener rather than a thousand. Understanding the phases also explains stopPropagation, why preventDefault does not stop bubbling, and why removing a listener requires the same function reference you added.
The Two Phases
Capture goes down, bubble comes back up. The third argument to addEventListener chooses which phase you listen in.
// document -> ... -> parent -> TARGET -> parent -> ... -> document
// capture phase | bubble phase
parent.addEventListener('click', h) // bubble (default)
parent.addEventListener('click', h, true) // capture
parent.addEventListener('click', h, { capture: true, once: true })
// e.target — where it actually happened
// e.currentTarget — where this listener is attached
parent.addEventListener('click', e => {
e.target // the button that was clicked
e.currentTarget // parent
})
e.stopPropagation() // stop travelling further
e.preventDefault() // cancel the default action; does NOT stop bubblingDelegation
One listener on a container, using closest() to find which item was hit. Works for elements added later, which direct listeners do not.
// One listener for any number of rows, present or future
list.addEventListener('click', (e) => {
const row = e.target.closest('[data-problem-id]')
if (!row) return // clicked the gap, not a row
openProblem(row.dataset.problemId)
})
// Without delegation you would attach per row, and re-attach
// every time the list re-renders.
// Some events do not bubble — use capture or the focus* variants:
// focus / blur -> do not bubble; focusin / focusout do
// mouseenter/leave -> do not bubble; mouseover/mouseout doRemoving Listeners
removeEventListener needs the identical function reference. An inline arrow can never be removed, which is a steady source of leaks.
// Cannot be removed — a new function every time
el.addEventListener('click', () => handle())
el.removeEventListener('click', () => handle()) // different function
// Correct — keep the reference
const onClick = () => handle()
el.addEventListener('click', onClick)
el.removeEventListener('click', onClick)
// Easier — AbortController removes many at once
const c = new AbortController()
el.addEventListener('click', onClick, { signal: c.signal })
window.addEventListener('resize', onResize, { signal: c.signal })
c.abort() // removes both
// In React, that maps onto effect cleanup:
useEffect(() => {
const c = new AbortController()
window.addEventListener('keydown', onKey, { signal: c.signal })
return () => c.abort()
}, [])Key Points to Remember
- 1Events capture down to the target then bubble back up; listeners default to the bubble phase
- 2e.target is where the event happened, e.currentTarget is where the listener is attached
- 3Delegation puts one listener on a container and uses closest() — it also works for elements added later
- 4preventDefault cancels the default action; stopPropagation stops travel — they are unrelated
- 5removeEventListener needs the identical function reference; an AbortController signal removes many at once
Interview Questions
Sign in to ask AriaWhat is event delegation and why is it useful?
What is the difference between e.target and e.currentTarget?
Why does removeEventListener with an inline arrow function fail to remove the listener?
Ask Aria about Events — Bubbling, Delegation and Cleanup
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.