Learning Guides
Menu

Event Delegation & Bubbling

12 min readFrontend Patterns & Concepts

Event Delegation & Bubbling

Understanding how events flow through the DOM is fundamental to building efficient, maintainable frontend applications. Event delegation is one of the most powerful patterns that emerges from this understanding.

The Event Lifecycle

When an event occurs, it goes through three phases:

PLAINTEXT
┌─────────────────────────────────────────────────────┐
│  1. CAPTURING PHASE (top-down)                      │
│     window → document → html → body → ... → target  │
├─────────────────────────────────────────────────────┤
│  2. TARGET PHASE                                    │
│     Event reaches the actual element that was       │
│     clicked/interacted with                         │
├─────────────────────────────────────────────────────┤
│  3. BUBBLING PHASE (bottom-up)                      │
│     target → ... → body → html → document → window  │
└─────────────────────────────────────────────────────┘

Visualizing Event Flow

HTML
<div id="outer">
  <div id="inner">
    <button id="btn">Click Me</button>
  </div>
</div>
JAVASCRIPT
function logPhase(element, phase) {
  element.addEventListener(
    "click",
    (e) => {
      console.log(`${phase}: ${element.id || element.tagName}`);
    },
    phase === "capture",
  );
}
 
// Log all phases
["outer", "inner", "btn"].forEach((id) => {
  const el = document.getElementById(id);
  logPhase(el, "capture");
  logPhase(el, "bubble");
});
 
// Click the button - output:
// capture: outer
// capture: inner
// capture: btn (target phase, but registered as capture)
// bubble: btn  (target phase, but registered as bubble)
// bubble: inner
// bubble: outer

Event Bubbling Deep Dive

Most events bubble up from the target to the root. This is the default behavior.

JAVASCRIPT
document.getElementById("child").addEventListener("click", (e) => {
  console.log("Child clicked");
  console.log("Target:", e.target); // Actual clicked element
  console.log("CurrentTarget:", e.currentTarget); // Element with the handler
});
 
document.getElementById("parent").addEventListener("click", (e) => {
  console.log("Parent received bubble");
  console.log("Target:", e.target); // Still the original target
  console.log("CurrentTarget:", e.currentTarget); // Now the parent
});

target vs currentTarget

This distinction is crucial:

JAVASCRIPT
document.getElementById("list").addEventListener("click", (e) => {
  console.log("e.target:", e.target);
  // The actual element clicked (could be <li>, <span>, <a> inside <li>)
 
  console.log("e.currentTarget:", e.currentTarget);
  // Always the element with the listener attached (the <ul>)
});

Events That Don't Bubble

Some events don't bubble by design:

JAVASCRIPT
const events = {
  bubbles: ["click", "mousedown", "keydown", "input", "change", "submit"],
  doesNotBubble: [
    "focus",
    "blur",
    "mouseenter",
    "mouseleave",
    "load",
    "scroll",
  ],
};
 
// For non-bubbling events, use capture phase
document.addEventListener(
  "focus",
  (e) => {
    console.log("Something focused:", e.target);
  },
  true,
); // true = capture phase

Event Capturing

Capturing happens before bubbling, going from root to target.

JAVASCRIPT
// Third parameter: true = capture phase
element.addEventListener("click", handler, true);
 
// Or use options object (more explicit)
element.addEventListener("click", handler, { capture: true });

When to Use Capturing

JAVASCRIPT
// 1. Intercept events before they reach targets
document.addEventListener(
  "click",
  (e) => {
    if (e.target.matches(".disabled")) {
      e.stopPropagation();
      console.log("Blocked click on disabled element");
    }
  },
  true,
);
 
// 2. Handle focus/blur across document
document.addEventListener(
  "focus",
  (e) => {
    trackFocusedElement(e.target);
  },
  true,
);
 
// 3. Create modal trap
modal.addEventListener(
  "click",
  (e) => {
    // Stop all clicks inside modal from reaching document
    e.stopPropagation();
  },
  true,
);

Stopping Propagation

stopPropagation()

Prevents the event from continuing to other elements:

JAVASCRIPT
element.addEventListener("click", (e) => {
  e.stopPropagation();
  // Event won't bubble up to parent elements
  // Other handlers on THIS element still run
});

stopImmediatePropagation()

Stops propagation AND prevents other handlers on the same element:

JAVASCRIPT
element.addEventListener("click", (e) => {
  console.log("First handler");
});
 
element.addEventListener("click", (e) => {
  e.stopImmediatePropagation();
  console.log("Second handler - stops here");
});
 
element.addEventListener("click", (e) => {
  console.log("Third handler - never runs");
});

When NOT to Stop Propagation

JAVASCRIPT
// ❌ BAD: Breaks analytics, modals, dropdowns
button.addEventListener("click", (e) => {
  e.stopPropagation(); // Now document click handlers never fire!
  doSomething();
});
 
// ✅ BETTER: Use event.defaultPrevented or custom flags
button.addEventListener("click", (e) => {
  e.preventDefault();
  e.handledByButton = true;
  doSomething();
});
 
document.addEventListener("click", (e) => {
  if (e.handledByButton) return; // Check flag instead
  closeDropdowns();
});

Event Delegation

Instead of attaching handlers to every element, attach one handler to a parent.

Why Event Delegation?

JAVASCRIPT
// ❌ Without delegation - 1000 listeners
const items = document.querySelectorAll(".list-item");
items.forEach((item) => {
  item.addEventListener("click", handleClick);
});
 
// Problems:
// - Memory overhead (1000 function references)
// - New items need new listeners
// - Cleanup complexity
// - Slow initial setup
 
// ✅ With delegation - 1 listener
document.getElementById("list").addEventListener("click", (e) => {
  if (e.target.matches(".list-item")) {
    handleClick(e);
  }
});
 
// Benefits:
// - Single listener, minimal memory
// - Works for dynamically added items
// - Easy cleanup (one removeEventListener)
// - Fast setup

Basic Delegation Pattern

JAVASCRIPT
class DelegatedList {
  constructor(container) {
    this.container = container;
    this.setupListeners();
  }
 
  setupListeners() {
    this.container.addEventListener("click", this.handleClick.bind(this));
  }
 
  handleClick(e) {
    const target = e.target;
 
    // Handle different actions
    if (target.matches(".delete-btn")) {
      this.handleDelete(target);
    } else if (target.matches(".edit-btn")) {
      this.handleEdit(target);
    } else if (target.matches(".list-item")) {
      this.handleSelect(target);
    }
  }
 
  handleDelete(button) {
    const item = button.closest(".list-item");
    item.remove();
  }
 
  handleEdit(button) {
    const item = button.closest(".list-item");
    this.openEditor(item);
  }
 
  handleSelect(item) {
    document.querySelectorAll(".list-item").forEach((i) => {
      i.classList.remove("selected");
    });
    item.classList.add("selected");
  }
}

Using closest() for Nested Elements

HTML
<ul id="users">
  <li class="user-card" data-id="1">
    <div class="avatar">
      <img src="avatar.jpg" />
    </div>
    <div class="info">
      <span class="name">John Doe</span>
      <button class="delete">×</button>
    </div>
  </li>
</ul>
JAVASCRIPT
document.getElementById("users").addEventListener("click", (e) => {
  // User might click the img, span, or any nested element
  const userCard = e.target.closest(".user-card");
 
  if (!userCard) return; // Click was outside any card
 
  const userId = userCard.dataset.id;
 
  // Check what was clicked
  if (e.target.closest(".delete")) {
    deleteUser(userId);
  } else {
    selectUser(userId);
  }
});

Data Attributes for Context

HTML
<div id="actions">
  <button data-action="save" data-id="123">Save</button>
  <button data-action="delete" data-id="123">Delete</button>
  <button data-action="share" data-id="123" data-platform="twitter">
    Share
  </button>
</div>
JAVASCRIPT
const handlers = {
  save: (dataset) => saveItem(dataset.id),
  delete: (dataset) => deleteItem(dataset.id),
  share: (dataset) => shareItem(dataset.id, dataset.platform),
};
 
document.getElementById("actions").addEventListener("click", (e) => {
  const button = e.target.closest("[data-action]");
  if (!button) return;
 
  const { action, ...data } = button.dataset;
  const handler = handlers[action];
 
  if (handler) {
    handler(data);
  }
});

Advanced Delegation Patterns

1. Declarative Event Binding

JAVASCRIPT
class DeclarativeEvents {
  constructor(root) {
    this.root = root;
    this.handlers = new Map();
 
    // Single listener for all events
    ["click", "change", "input", "submit"].forEach((event) => {
      root.addEventListener(event, this.dispatch.bind(this));
    });
  }
 
  dispatch(e) {
    const handlerName = e.target.dataset[e.type];
    if (!handlerName) return;
 
    const handler = this.handlers.get(handlerName);
    if (handler) {
      handler.call(this, e);
    }
  }
 
  register(name, handler) {
    this.handlers.set(name, handler);
  }
}
 
// Usage
const app = new DeclarativeEvents(document.body);
 
app.register("submitForm", (e) => {
  e.preventDefault();
  // Handle form submission
});
 
app.register("toggleMenu", (e) => {
  // Handle menu toggle
});
HTML
<form data-submit="submitForm">
  <input type="text" name="email" />
  <button type="submit">Submit</button>
</form>
 
<button data-click="toggleMenu">Menu</button>

2. Scoped Event Delegation

JAVASCRIPT
class ScopedDelegate {
  constructor(scope, events) {
    this.scope = scope;
    this.events = events;
    this.boundHandler = this.handleEvent.bind(this);
 
    Object.keys(events).forEach((eventType) => {
      scope.addEventListener(eventType, this.boundHandler);
    });
  }
 
  handleEvent(e) {
    const eventConfig = this.events[e.type];
    if (!eventConfig) return;
 
    for (const [selector, handler] of Object.entries(eventConfig)) {
      const target = e.target.closest(selector);
      if (target && this.scope.contains(target)) {
        handler.call(target, e, target);
      }
    }
  }
 
  destroy() {
    Object.keys(this.events).forEach((eventType) => {
      this.scope.removeEventListener(eventType, this.boundHandler);
    });
  }
}
 
// Usage
const listDelegate = new ScopedDelegate(document.getElementById("todo-list"), {
  click: {
    ".delete": (e, target) => {
      target.closest(".todo-item").remove();
    },
    ".toggle": (e, target) => {
      target.closest(".todo-item").classList.toggle("completed");
    },
  },
  dblclick: {
    ".todo-item": (e, target) => {
      target.classList.add("editing");
    },
  },
});

3. Event Delegation with TypeScript

TYPESCRIPT
type EventMap = {
  [selector: string]: (event: Event, element: Element) => void;
};
 
type DelegateConfig = {
  [eventType: string]: EventMap;
};
 
function delegate(root: Element, config: DelegateConfig): () => void {
  const handlers: Array<[string, EventListener]> = [];
 
  Object.entries(config).forEach(([eventType, selectors]) => {
    const handler: EventListener = (event) => {
      const target = event.target as Element;
 
      Object.entries(selectors).forEach(([selector, callback]) => {
        const matched = target.closest(selector);
        if (matched && root.contains(matched)) {
          callback(event, matched);
        }
      });
    };
 
    root.addEventListener(eventType, handler);
    handlers.push([eventType, handler]);
  });
 
  // Return cleanup function
  return () => {
    handlers.forEach(([eventType, handler]) => {
      root.removeEventListener(eventType, handler);
    });
  };
}
 
// Usage
const cleanup = delegate(document.getElementById("app")!, {
  click: {
    "button.primary": (e, el) => handlePrimaryClick(el),
    "button.secondary": (e, el) => handleSecondaryClick(el),
    "[data-dismiss]": (e, el) => el.closest(".modal")?.remove(),
  },
  change: {
    'input[type="checkbox"]': (e, el) => handleCheckbox(el as HTMLInputElement),
  },
});
 
// Later: cleanup();

Real-World Scenarios

1. Dynamic Table with Sorting and Actions

JAVASCRIPT
class DataTable {
  constructor(container, options) {
    this.container = container;
    this.data = options.data || [];
    this.columns = options.columns || [];
    this.sortColumn = null;
    this.sortDirection = "asc";
 
    this.render();
    this.setupDelegation();
  }
 
  setupDelegation() {
    this.container.addEventListener("click", (e) => {
      // Header click - sorting
      const header = e.target.closest("th[data-sort]");
      if (header) {
        this.handleSort(header.dataset.sort);
        return;
      }
 
      // Row actions
      const action = e.target.closest("[data-action]");
      if (action) {
        const row = action.closest("tr");
        const id = row.dataset.id;
        this.handleAction(action.dataset.action, id);
        return;
      }
 
      // Row selection
      const row = e.target.closest("tbody tr");
      if (row) {
        this.handleRowClick(row);
      }
    });
 
    // Handle input changes (e.g., inline editing)
    this.container.addEventListener("change", (e) => {
      const input = e.target.closest("input, select");
      if (input) {
        const row = input.closest("tr");
        const field = input.dataset.field;
        this.handleFieldChange(row.dataset.id, field, input.value);
      }
    });
  }
 
  handleSort(column) {
    if (this.sortColumn === column) {
      this.sortDirection = this.sortDirection === "asc" ? "desc" : "asc";
    } else {
      this.sortColumn = column;
      this.sortDirection = "asc";
    }
 
    this.data.sort((a, b) => {
      const aVal = a[column];
      const bVal = b[column];
      const modifier = this.sortDirection === "asc" ? 1 : -1;
      return aVal > bVal ? modifier : -modifier;
    });
 
    this.render();
  }
 
  handleAction(action, id) {
    const actions = {
      edit: () => this.openEditModal(id),
      delete: () => this.confirmDelete(id),
      duplicate: () => this.duplicateRow(id),
    };
 
    actions[action]?.();
  }
 
  handleRowClick(row) {
    this.container.querySelectorAll("tr.selected").forEach((r) => {
      r.classList.remove("selected");
    });
    row.classList.add("selected");
  }
 
  render() {
    // Render table HTML
  }
}

2. Accordion/Collapse Component

JAVASCRIPT
class Accordion {
  constructor(container) {
    this.container = container;
    this.allowMultiple = container.dataset.multiple === "true";
 
    container.addEventListener("click", (e) => {
      const header = e.target.closest(".accordion-header");
      if (header) {
        this.toggle(header);
      }
    });
 
    // Keyboard navigation
    container.addEventListener("keydown", (e) => {
      const header = e.target.closest(".accordion-header");
      if (!header) return;
 
      switch (e.key) {
        case "Enter":
        case " ":
          e.preventDefault();
          this.toggle(header);
          break;
        case "ArrowDown":
          e.preventDefault();
          this.focusNext(header);
          break;
        case "ArrowUp":
          e.preventDefault();
          this.focusPrev(header);
          break;
      }
    });
  }
 
  toggle(header) {
    const item = header.closest(".accordion-item");
    const isOpen = item.classList.contains("open");
 
    if (!this.allowMultiple) {
      this.container.querySelectorAll(".accordion-item.open").forEach((i) => {
        if (i !== item) this.close(i);
      });
    }
 
    if (isOpen) {
      this.close(item);
    } else {
      this.open(item);
    }
  }
 
  open(item) {
    const content = item.querySelector(".accordion-content");
    item.classList.add("open");
    item
      .querySelector(".accordion-header")
      .setAttribute("aria-expanded", "true");
    content.style.maxHeight = content.scrollHeight + "px";
  }
 
  close(item) {
    const content = item.querySelector(".accordion-content");
    item.classList.remove("open");
    item
      .querySelector(".accordion-header")
      .setAttribute("aria-expanded", "false");
    content.style.maxHeight = "0";
  }
 
  focusNext(current) {
    const headers = [...this.container.querySelectorAll(".accordion-header")];
    const index = headers.indexOf(current);
    const next = headers[index + 1] || headers[0];
    next.focus();
  }
 
  focusPrev(current) {
    const headers = [...this.container.querySelectorAll(".accordion-header")];
    const index = headers.indexOf(current);
    const prev = headers[index - 1] || headers[headers.length - 1];
    prev.focus();
  }
}

3. Dropdown Menu System

JAVASCRIPT
class DropdownManager {
  constructor() {
    this.activeDropdown = null;
 
    // Global delegation for all dropdowns
    document.addEventListener("click", (e) => {
      const trigger = e.target.closest("[data-dropdown-trigger]");
      const dropdown = e.target.closest(".dropdown");
 
      if (trigger) {
        e.preventDefault();
        this.toggle(trigger);
      } else if (!dropdown) {
        // Click outside - close all
        this.closeAll();
      }
    });
 
    // Handle dropdown item clicks
    document.addEventListener("click", (e) => {
      const item = e.target.closest(".dropdown-item");
      if (item && !item.dataset.keepOpen) {
        this.closeAll();
      }
    });
 
    // Keyboard navigation
    document.addEventListener("keydown", (e) => {
      if (!this.activeDropdown) return;
 
      const items = this.activeDropdown.querySelectorAll(
        ".dropdown-item:not([disabled])",
      );
      const focused = document.activeElement;
      const index = [...items].indexOf(focused);
 
      switch (e.key) {
        case "Escape":
          this.closeAll();
          break;
        case "ArrowDown":
          e.preventDefault();
          const next = items[index + 1] || items[0];
          next?.focus();
          break;
        case "ArrowUp":
          e.preventDefault();
          const prev = items[index - 1] || items[items.length - 1];
          prev?.focus();
          break;
      }
    });
  }
 
  toggle(trigger) {
    const dropdownId = trigger.dataset.dropdownTrigger;
    const dropdown = document.getElementById(dropdownId);
 
    if (this.activeDropdown === dropdown) {
      this.close(dropdown);
    } else {
      this.closeAll();
      this.open(dropdown, trigger);
    }
  }
 
  open(dropdown, trigger) {
    dropdown.classList.add("open");
    trigger.setAttribute("aria-expanded", "true");
    this.activeDropdown = dropdown;
 
    // Position dropdown
    this.position(dropdown, trigger);
 
    // Focus first item
    const firstItem = dropdown.querySelector(".dropdown-item");
    firstItem?.focus();
  }
 
  close(dropdown) {
    dropdown.classList.remove("open");
    const trigger = document.querySelector(
      `[data-dropdown-trigger="${dropdown.id}"]`,
    );
    trigger?.setAttribute("aria-expanded", "false");
    trigger?.focus();
    this.activeDropdown = null;
  }
 
  closeAll() {
    if (this.activeDropdown) {
      this.close(this.activeDropdown);
    }
  }
 
  position(dropdown, trigger) {
    const rect = trigger.getBoundingClientRect();
    const dropRect = dropdown.getBoundingClientRect();
 
    // Default: below trigger
    let top = rect.bottom + 4;
    let left = rect.left;
 
    // Flip if not enough space below
    if (top + dropRect.height > window.innerHeight) {
      top = rect.top - dropRect.height - 4;
    }
 
    // Keep within viewport horizontally
    if (left + dropRect.width > window.innerWidth) {
      left = window.innerWidth - dropRect.width - 8;
    }
 
    dropdown.style.position = "fixed";
    dropdown.style.top = `${top}px`;
    dropdown.style.left = `${left}px`;
  }
}
 
// Initialize once
const dropdowns = new DropdownManager();

Performance Considerations

1. Selector Matching Performance

JAVASCRIPT
// ❌ Slow: Complex selector
element.matches(".sidebar .list > li.active .button.primary");
 
// ✅ Fast: Simple selector
element.matches(".primary-button");
 
// ✅ Even better: Check class directly
element.classList.contains("primary-button");

2. Event Handler Performance

JAVASCRIPT
// ❌ Creating closures in loop
items.forEach((item) => {
  item.addEventListener("click", () => {
    handleClick(item.dataset.id);
  });
});
 
// ✅ Delegation - one function
container.addEventListener("click", (e) => {
  const item = e.target.closest(".item");
  if (item) handleClick(item.dataset.id);
});

3. Passive Event Listeners

JAVASCRIPT
// For scroll/touch - improves performance
element.addEventListener("scroll", handler, { passive: true });
element.addEventListener("touchstart", handler, { passive: true });
 
// Note: Can't call preventDefault() in passive handlers
element.addEventListener(
  "wheel",
  (e) => {
    // e.preventDefault(); // Would throw error
  },
  { passive: true },
);
 
// If you need preventDefault:
element.addEventListener(
  "wheel",
  (e) => {
    e.preventDefault();
    customScroll(e);
  },
  { passive: false },
); // Explicit opt-out

Summary

ConceptDescriptionUse Case
BubblingEvents travel up from targetDefault behavior, most common
CapturingEvents travel down to targetIntercept before child handlers
stopPropagationStop event from travelingPrevent parent handlers
Event DelegationSingle parent handlerDynamic content, many elements
closest()Find ancestor matching selectorIdentify clicked item in delegation
target vs currentTargetActual vs handler elementEssential for delegation

Key Takeaways:

  1. Always prefer delegation for lists, tables, and dynamic content
  2. Use closest() to find the relevant element from any nested click
  3. Avoid stopPropagation() unless absolutely necessary
  4. Use capture phase for focus/blur and intercepting events
  5. Make event handlers fast - especially for high-frequency events