Event Delegation & Bubbling
12 min read•Frontend 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: outerEvent 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 phaseEvent 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 setupBasic 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-outSummary
| Concept | Description | Use Case |
|---|---|---|
| Bubbling | Events travel up from target | Default behavior, most common |
| Capturing | Events travel down to target | Intercept before child handlers |
| stopPropagation | Stop event from traveling | Prevent parent handlers |
| Event Delegation | Single parent handler | Dynamic content, many elements |
| closest() | Find ancestor matching selector | Identify clicked item in delegation |
| target vs currentTarget | Actual vs handler element | Essential for delegation |
Key Takeaways:
- Always prefer delegation for lists, tables, and dynamic content
- Use
closest()to find the relevant element from any nested click - Avoid
stopPropagation()unless absolutely necessary - Use capture phase for focus/blur and intercepting events
- Make event handlers fast - especially for high-frequency events