DNA⚡ JavaScriptEvent Propagation, Bubbling & Delegation
ðŸĢHatchlingJavaScriptDOMEventsFundamentals

Event Propagation, Bubbling & Delegation

Events don't just fire on the target — they travel through the entire DOM tree. Understanding capturing, bubbling, and delegation is critical for building performant event-driven UIs.

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)
  1. Capture Phase — Event travels from window down to the target's parent
  2. Target Phase — Event reaches the actual element that was interacted with
  3. 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

ApproachListenersDynamic ElementsMemory
Per-elementN listenersMust re-attach on DOM changesO(n)
Delegation1 listenerWorks automaticallyO(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)
});
PropertyValue
e.targetThe originating element (deepest element clicked)
e.currentTargetThe element with the listener (always this in non-arrow functions)

Events That Don't Bubble

Not all events bubble. Key exceptions:

EventBubbles?Alternative
focus / blurNoUse focusin / focusout (they bubble)
mouseenter / mouseleaveNoUse mouseover / mouseout (they bubble)
load / unload / error (on elements)NoMust listen on target directly
scrollNo (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 focus

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

MethodDoes 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 handler

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

  1. Three-phase model — Capture → target → bubble, and when to use capture
  2. Delegation mastery — Single listener + closest() pattern, knows which events don't bubble
  3. target vs currentTarget — Clear distinction, knows currentTarget is always the listener's element
  4. preventDefault vs stopPropagation — Completely different purposes, can articulate both
  5. React awareness — Synthetic events, root delegation in React 17+, event pooling history