
Event Bubbling & Capturing
Clicked a button, but the parent DIV triggered too? Events bubble up like water. Understand Propagation and Delegation.

Clicked a button, but the parent DIV triggered too? Events bubble up like water. Understand Propagation and Delegation.
Why does my server crash? OS's desperate struggle to manage limited memory. War against Fragmentation.

Two ways to escape a maze. Spread out wide (BFS) or dig deep (DFS)? Who finds the shortest path?

A comprehensive deep dive into client-side storage. From Cookies to IndexedDB and the Cache API. We explore security best practices for JWT storage (XSS vs CSRF), performance implications of synchronous APIs, and how to build offline-first applications using Service Workers.

Fast by name. Partitioning around a Pivot. Why is it the standard library choice despite O(N²) worst case?

I was tasked with implementing a modal. The requirement was simple: "Close the modal when clicking outside of it." Seemed straightforward. Just attach a click handler to the modal background, call setIsOpen(false) inside, and done, right? That's what I thought.
function Modal({ children }) {
return (
<div className="overlay" onClick={() => setIsOpen(false)}>
<div className="modal-content">
{children}
</div>
</div>
);
}
The problem was that clicking a button inside the modal also closed it. I wanted "outside" clicks only, but even internal buttons triggered the close. Clicking inside was being interpreted as clicking outside. This absurd situation confused me. I assumed events only fired where they were clicked. I was completely wrong.
I learned that when an event occurs in the browser, it goes through three phases. Like an elevator starting from the top floor, stopping at your destination, then traveling back up.
window and descends to the clicked elementI understood this as "a water droplet falling." The droplet descends along tree branches (parents) during capture, hits a leaf (target), then bounces back up the branches (bubbling).
By default, when we register events with addEventListener, they execute during the bubble phase. This is what happened in my modal:
modal-contentoverlayoverlay's onClick triggersI clicked the button, but the event "bubbled up" to the parent overlay, closing the modal. I thought events stopped where they were clicked, but they actually keep traveling up through parents.
While debugging by logging various things to the console, I discovered that event.target and event.currentTarget were different.
<div className="overlay" onClick={(e) => {
console.log('target:', e.target); // <button>
console.log('currentTarget:', e.currentTarget); // <div class="overlay">
}}>
<div className="modal-content">
<button>Inner Button</button>
</div>
</div>
When I click the button:
e.target is <button>e.currentTarget is <div className="overlay">Understanding this difference let me solve the modal problem.
function Modal({ children, onClose }) {
return (
<div
className="overlay"
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div className="modal-content">
{children}
</div>
</div>
);
}
"Close the modal only when the clicked element is the overlay itself." When clicking the button, e.target is the button and e.currentTarget is the overlay, so the condition is false and the modal stays open. Perfect.
There was another solution: using e.stopPropagation(). This command says "stop event propagation here."
function Modal({ children, onClose }) {
return (
<div className="overlay" onClick={onClose}>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
}
When clicking modal-content, stopPropagation() fires immediately, preventing the event from bubbling to the parent overlay. It cuts the bubble chain mid-way.
But I didn't use this approach. I later learned that overusing stopPropagation() can cause unexpected side effects. For example, analytics tools like Google Tag Manager typically attach events at the document level to track all clicks. If you stopPropagation() mid-way, the event never reaches document, and your analytics data gets lost.
That's why I prefer the e.target === e.currentTarget check. It controls the desired behavior without blocking the event flow itself.
There's a stronger sibling of stopPropagation(): stopImmediatePropagation().
stopPropagation() only stops bubbling to parents. Other listeners on the same element still execute.
button.addEventListener('click', (e) => {
console.log('Listener 1');
e.stopPropagation();
});
button.addEventListener('click', (e) => {
console.log('Listener 2'); // This still runs
});
Output:
Listener 1
Listener 2
But with stopImmediatePropagation():
button.addEventListener('click', (e) => {
console.log('Listener 1');
e.stopImmediatePropagation();
});
button.addEventListener('click', (e) => {
console.log('Listener 2'); // Doesn't run
});
Output:
Listener 1
It blocks all other listeners on the same element too. More powerful means more caution needed.
Initially, I confused preventDefault() with stopPropagation(). Both had this vibe of "blocking something."
But they serve completely different roles.
// Prevent form submission
form.addEventListener('submit', (e) => {
e.preventDefault(); // No page refresh
// Handle with AJAX
});
// Prevent link navigation
link.addEventListener('click', (e) => {
e.preventDefault(); // No page navigation
// Custom action
});
// Prevent context menu
document.addEventListener('contextmenu', (e) => {
e.preventDefault(); // No default context menu
});
I understood this as "not stopping the event itself, but stopping what the browser was about to do." The event still fires, bubbling still happens, but the browser just doesn't perform its automatic action like "oh, it's a link, let me navigate."
Most of the time, the bubble phase is all you need. But occasionally, capturing is necessary.
Put true or { capture: true } as the third argument of addEventListener to execute during the capture phase.
element.addEventListener('click', handler, true);
// or
element.addEventListener('click', handler, { capture: true });
I used capturing when implementing a "log all clicks" feature. During the bubble phase, child elements might have already called stopPropagation(), but capturing lets me catch all events without such interference.
document.addEventListener('click', (e) => {
console.log('Click occurred:', e.target);
// Send to logging server
}, true); // Execute in capture phase
After understanding bubbling, the "Event Delegation" pattern caught my eye. This was one of the most powerful frontend patterns I've encountered.
Imagine building a todo list with 100 items, each with a delete button.
Bad approach:const items = document.querySelectorAll('.todo-item');
items.forEach(item => {
const deleteBtn = item.querySelector('.delete-btn');
deleteBtn.addEventListener('click', () => {
item.remove();
});
});
Problems:
const todoList = document.querySelector('.todo-list');
todoList.addEventListener('click', (e) => {
if (e.target.classList.contains('delete-btn')) {
e.target.closest('.todo-item').remove();
}
});
Attach just one listener to the parent todo-list. Thanks to bubbling, clicking any delete button bubbles the event to the parent. Check e.target there to determine "oh, a delete button was clicked" and handle it.
Benefits:
In real projects with dynamically added list items, this pattern made my code incredibly clean.
function TodoApp() {
const [todos, setTodos] = useState([]);
const handleListClick = (e) => {
// Delete button click
if (e.target.classList.contains('delete-btn')) {
const id = e.target.closest('.todo-item').dataset.id;
setTodos(todos.filter(todo => todo.id !== id));
}
// Complete checkbox click
if (e.target.type === 'checkbox') {
const id = e.target.closest('.todo-item').dataset.id;
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
}
// Edit button click
if (e.target.classList.contains('edit-btn')) {
const id = e.target.closest('.todo-item').dataset.id;
// Enter edit mode
}
};
return (
<ul className="todo-list" onClick={handleListClick}>
{todos.map(todo => (
<li key={todo.id} className="todo-item" data-id={todo.id}>
<input type="checkbox" checked={todo.completed} />
<span>{todo.text}</span>
<button className="edit-btn">Edit</button>
<button className="delete-btn">Delete</button>
</li>
))}
</ul>
);
}
One listener handles delete, complete, and edit operations. Add a new todo and it works automatically. Felt like magic.
Using React, I was sometimes confused when behavior differed from native DOM. Turns out React uses Synthetic Events.
Before React 17:
document levelonClick to an element, it's actually attached to documente.stopPropagation() didn't matter because native DOM events had already bubbled to documentReact 17 onwards:
<div id="root">)That's why in React modals, you sometimes attach native events separately in useEffect:
function Modal({ isOpen, onClose, children }) {
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>
);
}
The ESC key close feature uses native document.addEventListener instead of React's event system. This ensures reliable behavior regardless of where focus is.
My go-to method for debugging event issues:
element.addEventListener('click', (e) => {
console.log('=== Event Info ===');
console.log('target:', e.target); // Actually clicked element
console.log('currentTarget:', e.currentTarget); // Element with listener
console.log('eventPhase:', e.eventPhase); // 1: capture, 2: target, 3: bubble
console.log('bubbles:', e.bubbles); // Does this event bubble?
console.log('defaultPrevented:', e.defaultPrevented); // Was preventDefault called?
// Print event path (Chrome)
if (e.composedPath) {
console.log('Path:', e.composedPath());
}
});
e.composedPath() was particularly useful. It shows the event propagation path as an array.
// [button, div.modal-content, div.overlay, body, html, document, window]
Looking at this makes it clear: "oh, stopPropagation here won't reach there."
A dashboard I built at work had a large table with hundreds of rows. Clicking each row should navigate to a detail page. But each row also had buttons (edit, delete, share).
function DataTable({ rows }) {
const handleTableClick = (e) => {
// Ignore button clicks
if (e.target.tagName === 'BUTTON') {
return;
}
// Ignore checkbox clicks
if (e.target.type === 'checkbox') {
return;
}
// Handle row click
const row = e.target.closest('tr');
if (row && row.dataset.id) {
navigate(`/detail/${row.dataset.id}`);
}
};
return (
<table onClick={handleTableClick}>
<tbody>
{rows.map(row => (
<tr key={row.id} data-id={row.id}>
<td><input type="checkbox" /></td>
<td>{row.name}</td>
<td>{row.email}</td>
<td>
<button onClick={(e) => {
e.stopPropagation();
handleEdit(row.id);
}}>Edit</button>
<button onClick={(e) => {
e.stopPropagation();
handleDelete(row.id);
}}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
);
}
Buttons use stopPropagation() to prevent propagating to the row click. At the table level, I only treat clicks that aren't buttons or checkboxes as row clicks.
Event bubbling initially made me think "why make it so complicated?" Wouldn't it be simpler if events just fired where they were clicked?
But after using it, I realized that without this mechanism, powerful patterns like event delegation wouldn't be possible. To handle dynamically added elements, you need to receive events at the parent, which would be impossible without bubbling.
Event bubbling ultimately:
e.target from e.currentTargetstopPropagation()Understanding these concepts made my frontend code much cleaner and reduced unexpected bugs. Especially when building UIs like modals or dropdowns, I realized how crucial it is to understand event flow precisely.