The Day I Couldn't Close a Modal
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.
Events Don't Stay Isolated
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.
- Capture Phase: Starts from
windowand descends to the clicked element - Target Phase: Reaches the actual clicked element
- Bubble Phase: Travels back up through the parents
I 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:
- I click the button
- Button's click event fires
- Event bubbles to parent
modal-content - Continues bubbling to parent
overlay overlay'sonClicktriggers- Modal closes
I 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.
event.target vs event.currentTarget Was Everything
While debugging by logging various things to the console, I discovered that event.target and event.currentTarget were different.
- event.target: The element that was actually clicked (the button)
- event.currentTarget: The element with the event listener attached (the overlay)
<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.targetis<button>e.currentTargetis<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.
Stopping Bubbling with stopPropagation
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.
stopImmediatePropagation Is More Powerful
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.
preventDefault Is a Different Story
Initially, I confused preventDefault() with stopPropagation(). Both had this vibe of "blocking something."
But they serve completely different roles.
- stopPropagation(): Stops events from propagating to parents
- preventDefault(): Prevents the browser's default behavior
// 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."
Actually Using the Capture Phase
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
Event Delegation Was a Real Game Changer
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:
- Creates 100 event listeners (memory waste)
- Add a new item later? Need to attach listeners again
- 1000 items? 1000 listeners
Event delegation approach:
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:
- Only 1 listener (memory efficient)
- Newly added items work automatically
- 10000 items? Still just 1 listener
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.
React's Synthetic Events Work Differently
Using React, I was sometimes confused when behavior differed from native DOM. Turns out React uses Synthetic Events.
Before React 17:
- All events were pooled at the
documentlevel - Even if you attach
onClickto an element, it's actually attached todocument - So
e.stopPropagation()didn't matter because native DOM events had already bubbled todocument
React 17 onwards:
- Events attach to the root container (usually
<div id="root">) - Behavior is more predictable
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.
Debugging Tricks I Use
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."
Real-World Pattern: Dynamic Table Row Clicks
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.
Closing Thoughts
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:
- Defaults to traveling from child to parent
- Requires distinguishing
e.targetfrome.currentTarget - Needs careful use of
stopPropagation() - Enables event delegation patterns to save memory and code
- Works slightly differently in React's Synthetic Events
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.