Event Propagation, Bubbling & Delegation
Every DOM event travels through three phases: capture, target, and bubble. Most developers only think about the target, but the full propagation model is what makes event delegation possible and what causes subtle bugs when events fire in unexpected places.
The Three Phases
â CAPTURE PHASE (top â target)
âž
ââââ window ââââ
â ââ document ââ â
â â ââ html âââ â â
â â â â body â â â â
â â â â div â â â â
â â â â btn ââââĪ TARGET PHASE
â â â â â â â â
â â â ââââââââ â â â
â â ââââââââââââââ â â
â ââââââââââââââââââââ â
ââââââââââââââââââââââââââ
â
âž BUBBLE PHASE (target â top)- Capture Phase â Event travels from
windowdown to the target's parent - Target Phase â Event reaches the actual element that was interacted with
- Bubble Phase â Event bubbles back up from the target to
window
document.querySelector('.parent').addEventListener('click', (e) => {
console.log('Parent - Capture');
}, true); // true = capture phase
document.querySelector('.child').addEventListener('click', (e) => {
console.log('Child - Target/Bubble');
});
document.querySelector('.parent').addEventListener('click', (e) => {
console.log('Parent - Bubble');
});
// Click on .child logs:
// "Parent - Capture"
// "Child - Target/Bubble"
// "Parent - Bubble"The third argument (true) or { capture: true } registers the listener on the capture phase. By default, listeners fire during the bubble phase.
Stopping Propagation
stopPropagation()
Prevents the event from continuing to the next element in the current phase:
child.addEventListener('click', (e) => {
e.stopPropagation();
console.log('Child clicked');
});
parent.addEventListener('click', () => {
console.log('Parent clicked'); // NEVER fires when child is clicked
});stopImmediatePropagation()
Stops propagation AND prevents other listeners on the same element from firing:
btn.addEventListener('click', (e) => {
e.stopImmediatePropagation();
console.log('First handler');
});
btn.addEventListener('click', () => {
console.log('Second handler'); // NEVER fires
});When to Use (and When Not To)
stopPropagation() breaks event delegation and third-party analytics listeners. Use it sparingly. Prefer checking e.target or e.currentTarget to conditionally handle events.
Event Delegation
Instead of attaching a listener to every child, attach one listener to the parent and use e.target to determine which child was clicked:
// BAD: One listener per item (memory waste, breaks on dynamic items)
document.querySelectorAll('.list-item').forEach(item => {
item.addEventListener('click', handleClick);
});
// GOOD: One listener on parent (works for dynamic items too)
document.querySelector('.list').addEventListener('click', (e) => {
const item = e.target.closest('.list-item');
if (!item) return;
handleClick(item);
});Why Delegation Wins
| Approach | Listeners | Dynamic Elements | Memory |
|---|---|---|---|
| Per-element | N listeners | Must re-attach on DOM changes | O(n) |
| Delegation | 1 listener | Works automatically | O(1) |
The closest() Pattern
e.target might be a deeply nested element (an <img> inside a <button> inside a <li>). Use closest() to find the relevant ancestor:
list.addEventListener('click', (e) => {
const deleteBtn = e.target.closest('[data-action="delete"]');
if (deleteBtn) {
const itemId = deleteBtn.closest('[data-id]').dataset.id;
deleteItem(itemId);
return;
}
const editBtn = e.target.closest('[data-action="edit"]');
if (editBtn) {
const itemId = editBtn.closest('[data-id]').dataset.id;
editItem(itemId);
return;
}
});e.target vs e.currentTarget
parent.addEventListener('click', (e) => {
console.log(e.target); // The element that was ACTUALLY clicked
console.log(e.currentTarget); // The element the listener is ATTACHED to (parent)
});| Property | Value |
|---|---|
e.target | The originating element (deepest element clicked) |
e.currentTarget | The element with the listener (always this in non-arrow functions) |
Events That Don't Bubble
Not all events bubble. Key exceptions:
| Event | Bubbles? | Alternative |
|---|---|---|
focus / blur | No | Use focusin / focusout (they bubble) |
mouseenter / mouseleave | No | Use mouseover / mouseout (they bubble) |
load / unload / error (on elements) | No | Must listen on target directly |
scroll | No (on most elements) | Listen on the scrolling element directly |
// focus doesn't bubble â delegation won't work
parent.addEventListener('focus', handler); // Never fires for child focus
// focusin DOES bubble â delegation works
parent.addEventListener('focusin', handler); // Fires when any child gains focusEvent Delegation in React
React uses synthetic events with automatic delegation. All events are delegated to the root:
function List({ items, onDelete }) {
return (
<ul onClick={(e) => {
const btn = (e.target as HTMLElement).closest('button[data-id]');
if (btn) onDelete(btn.dataset.id);
}}>
{items.map(item => (
<li key={item.id}>
{item.name}
<button data-id={item.id}>Delete</button>
</li>
))}
</ul>
);
}React 17+ attaches event listeners to the root container (not document), which improves compatibility with multiple React roots and third-party code.
preventDefault() vs stopPropagation()
These are completely different mechanisms:
| Method | Does What |
|---|---|
preventDefault() | Cancels the browser's default behavior (navigation, form submit, text selection) |
stopPropagation() | Stops the event from traveling to parent/child elements |
link.addEventListener('click', (e) => {
e.preventDefault(); // Don't navigate to href
e.stopPropagation(); // Don't trigger parent click handlers
});Practical Patterns
Confirm Before Action
document.addEventListener('click', (e) => {
const dangerBtn = e.target.closest('[data-confirm]');
if (dangerBtn) {
const message = dangerBtn.dataset.confirm;
if (!confirm(message)) {
e.preventDefault();
e.stopPropagation();
}
}
}, true); // Capture phase â fires BEFORE any other handlerClick Outside to Close
function onClickOutside(element, callback) {
document.addEventListener('click', (e) => {
if (!element.contains(e.target)) {
callback();
}
});
}
onClickOutside(dropdown, () => dropdown.classList.remove('open'));Interview Signal
Senior candidates demonstrate:
- Three-phase model â Capture â target â bubble, and when to use capture
- Delegation mastery â Single listener +
closest()pattern, knows which events don't bubble targetvscurrentTargetâ Clear distinction, knowscurrentTargetis always the listener's elementpreventDefaultvsstopPropagationâ Completely different purposes, can articulate both- React awareness â Synthetic events, root delegation in React 17+, event pooling history