When you click a button inside a card inside a list, which element did you click? All of them, in a sense. A click doesn't just happen on the button: the event travels up through every ancestor, from the button to the card, to the list, to the <body>, all the way to the document. This journey is called event bubbling, and once you understand it, a whole class of event-handling problems becomes easy.
The most useful technique it enables is event delegation. Instead of attaching a separate listener to each of a hundred list items, you attach one listener to the list and check which item the click came from. It uses less memory, and, crucially, it keeps working for items you add later with JavaScript, which individual listeners don't.
In this lesson you'll learn the three phases of an event, the difference between event.target and event.currentTarget, when to stop an event spreading, how delegation works with closest, and useful listener options like once and passive. You learned the basics of listeners in JavaScript events.
How events travel
An event goes through three phases:
| Phase | Direction | Listeners that run |
|---|---|---|
| 1. Capturing | From window down to the target's parent | Those added with { capture: true } |
| 2. Target | At the element that was actually clicked | Listeners on the target itself |
| 3. Bubbling | From the target's parent back up to window | Normal listeners (the default) |
Almost all listeners use the bubbling phase. Most events bubble, including click, input, keydown and submit. A few don't, such as focus, blur, mouseenter and mouseleave; use focusin, focusout, mouseover and mouseout when you need bubbling versions.
target vs currentTarget
| Property | Is… |
|---|---|
event.target | The element where the event started: the innermost element clicked |
event.currentTarget | The element whose listener is running right now |
If a list has a listener and you click a <span> inside an <li>, target is the <span> and currentTarget is the list. That's why delegation code usually does event.target.closest("li"): it climbs from wherever the click landed to the item you care about.
Event delegation
list.addEventListener("click", (event) => {
const item = event.target.closest("li");
if (!item || !list.contains(item)) return; // click wasn't on an item
item.classList.toggle("done");
});- One listener handles every item, however many there are.
- Items added later work automatically, because the listener is on the parent.
- Use
data-*attributes to tell actions apart:<button data-action="delete">, then checkevent.target.closest("[data-action]")?.dataset.action. matches(selector)checks whether an element fits a selector, which is useful when the target itself is what you want.
Stopping and preventing
| Method | Does |
|---|---|
event.preventDefault() | Cancels the browser's default action: following a link, submitting a form, ticking a box |
event.stopPropagation() | Stops the event travelling further up (or down) the tree |
event.stopImmediatePropagation() | Also stops other listeners on the same element |
These are different things: preventDefault doesn't stop bubbling, and stopPropagation doesn't stop the default action. Use stopPropagation sparingly. It can break other code that relies on events reaching the document, such as analytics, or "click outside to close" menus.
Listener options
btn.addEventListener("click", handler, { once: true }); // removes itself after one run
window.addEventListener("scroll", handler, { passive: true }); // promises not to call preventDefault
el.addEventListener("click", handler, { capture: true }); // runs in the capture phase
const controller = new AbortController();
el.addEventListener("click", handler, { signal: controller.signal });
controller.abort(); // removes every listener that uses this signalpassive: true lets the browser scroll smoothly without waiting for your code. signal is a tidy way to remove several listeners at once.
JavaScript Event Delegation Syntax
Events and delegation
parent.addEventListener("click", (e) => {
const btn = e.target.closest("[data-action]");
if (!btn) return;
if (btn.dataset.action === "delete") btn.closest("li").remove();
});
link.addEventListener("click", (e) => e.preventDefault());
inner.addEventListener("click", (e) => e.stopPropagation());
el.addEventListener("click", fn, { once: true });JavaScript Event Delegation Summary
- Events capture down to the target, then bubble back up through every ancestor.
event.targetis where the event started;event.currentTargetis where the listener is.- Event delegation puts one listener on a parent and uses
target.closest()to find the item. - Delegated listeners work for elements added later.
preventDefaultcancels default actions;stopPropagationstops bubbling, so use it sparingly.
JavaScript Event Delegation Examples
Each example runs in the page. Edit the code and press Run to see what changes.
Watching an event bubble
Click the button, then click the white area of the card.
<div id="outer" class="box">outer
<div id="card" class="box">card
<button id="btn">button</button>
</div>
</div>
<style>.box { padding: 14px; border: 2px solid #94a3b8; margin: 6px; font-family: sans-serif; }</style>for (const id of ["btn", "card", "outer"]) {
document.getElementById(id).addEventListener("click", (e) => {
console.log(`${id} heard it | target: ${e.target.id} | currentTarget: ${e.currentTarget.id}`);
});
}
document.getElementById("outer").addEventListener("click", () => {
console.log("outer (capture phase) runs first");
}, { capture: true });Line-by-line explanation
- Lines 1 to 5Listeners on three nested elements log who heard the click.
- Line 3
targetstays the same, whilecurrentTargetchanges as the event bubbles up. - Lines 7 to 9A capturing listener on the outer box runs before any of the bubbling ones.
One listener for a whole list
Add items, then tick or delete them. New items work without new listeners.
<ul id="tasks">
<li><span>Water plants</span> <button data-action="done">Done</button> <button data-action="delete">Delete</button></li>
</ul>
<button id="add">Add task</button>
<style>.is-done span { text-decoration: line-through; opacity: 0.6; }</style>const tasks = document.querySelector("#tasks");
let n = 1;
tasks.addEventListener("click", (e) => {
const btn = e.target.closest("[data-action]");
if (!btn) return;
const item = btn.closest("li");
if (btn.dataset.action === "done") item.classList.toggle("is-done");
if (btn.dataset.action === "delete") item.remove();
});
document.querySelector("#add").addEventListener("click", () => {
const li = tasks.firstElementChild?.cloneNode(true) ?? document.createElement("li");
li.classList.remove("is-done");
li.querySelector("span").textContent = `New task ${++n}`;
tasks.append(li);
});Line-by-line explanation
- Line 4A single listener on the list.
- Lines 5 to 6Ignore clicks that weren't on an action button.
- Line 7Find the list item that the button belongs to.
- Lines 8 to 9Decide what to do from the button's
data-action. - Lines 12 to 17Items added later are handled by the same listener.
preventDefault, stopPropagation and click outside
<a id="link" href="https://example.com">A link that won't navigate</a>
<div id="menu" style="border:1px solid #ccc; padding:10px; margin:10px 0">
Menu (click inside: stays open)
<button id="inner">Inner button</button>
</div>
<p id="state">Menu is open</p>document.querySelector("#link").addEventListener("click", (e) => {
e.preventDefault();
console.log("Navigation cancelled");
});
const menu = document.querySelector("#menu");
const state = document.querySelector("#state");
document.addEventListener("click", (e) => {
state.textContent = menu.contains(e.target) ? "Menu is open" : "Clicked outside: menu closed";
});
document.querySelector("#inner").addEventListener("click", () => {
console.log("Inner button clicked, and the event still bubbles to the document");
}, { once: true });Line-by-line explanation
- Line 2The link's default action, navigating, is cancelled.
- Lines 9 to 11A document listener checks whether the click came from inside the menu.
- Lines 13 to 15
onceremoves this listener after the first click.
Common JavaScript Event Delegation Mistakes
Adding listeners to every item in a loop
document.querySelectorAll("li").forEach(li => li.addEventListener("click", select));
// items added later never get the listenerlist.addEventListener("click", (e) => {
const li = e.target.closest("li");
if (li) select(li);
});Using target when the click lands on a child
list.addEventListener("click", (e) => {
e.target.classList.toggle("done"); // may be the <span> or <img> inside the li
});list.addEventListener("click", (e) => {
e.target.closest("li")?.classList.toggle("done");
});stopPropagation to fix a symptom
menuButton.addEventListener("click", (e) => { e.stopPropagation(); toggle(); });
// breaks other document-level listeners, like analytics or other menusdocument.addEventListener("click", (e) => {
if (!menu.contains(e.target) && !menuButton.contains(e.target)) close();
});JavaScript Event Delegation in Real Projects
Delegation in the wild
Email inboxes, data tables and kanban boards with hundreds of rows use one delegated listener per list. React attaches a single listener at the root of your app and delegates every event from there. Analytics scripts listen for clicks on the document and read data-track attributes from whatever was clicked. Dropdown menus use document-level listeners to close when you click outside.
JavaScript Event Delegation Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Find the list item that was clicked, even if the click landed on something inside it.
list.addEventListener("click", (event) => {
const item = event.target.("li");
console.log(item?.textContent);
});
closest climbs up to the nearest matching ancestor.A list ul has a click listener, and you click a <b> inside one of its <li> elements. What are e.target.tagName and e.currentTarget.tagName?
ul.addEventListener("click", (e) => console.log(e.target.tagName, e.currentTarget.tagName));What is event bubbling?
What's the main advantage of event delegation?
Clicking any Remove button, including on rows added later, should remove its own row. Fix it with delegation.
<ul id="rows">
<li>Row 1 <button class="rm">Remove</button></li>
</ul>document.querySelectorAll(".rm").forEach((btn) => {
btn.addEventListener("click", (e) => e.currentTarget.parentElement.parentElement.remove());
});
const extra = document.createElement("li");
extra.innerHTML = 'Row 2 <button class="rm">Remove</button>';
document.querySelector("#rows").append(extra);- The added row's button works
- The list itself survives
JavaScript Event Delegation Quiz
5 questions to check what you learned.
Which method stops a link from navigating?
- stopPropagation()
- preventDefault()
- return false in addEventListener
- removeEventListener()
Which of these events does NOT bubble?
- focus
- click
- input
- keydown
What does { once: true } do?
- Runs the listener in the capture phase
- Prevents the default action
- Stops bubbling
- Removes the listener automatically after it runs once
How do you check whether a click happened outside a menu?
- !menu.contains(event.target)
- event.target === document
- event.outside
- menu.isClicked === false
Which phase runs listeners added with { capture: true }?
- Bubbling
- Only the target phase
- Capturing, on the way down before the target
- After all bubbling listeners
JavaScript Event Delegation Challenge
A delegated colour picker
#palette contains buttons with data-colour attributes. Using one listener on #palette:
- When a swatch button is clicked (even if the click lands on its inner
<span>), set#preview'sstyle.backgroundto the button'sdata-colour - Give the clicked button the class
activeand removeactivefrom all the others - Ignore clicks on the palette's gaps (not on a button)
<div id="palette">
<button data-colour="rgb(239, 68, 68)"><span>Red</span></button>
<button data-colour="rgb(34, 197, 94)"><span>Green</span></button>
<button data-colour="rgb(59, 130, 246)"><span>Blue</span></button>
</div>
<div id="preview" style="height: 40px; margin-top: 8px; border: 1px solid #ccc"></div>const palette = document.querySelector("#palette");
const preview = document.querySelector("#preview");
- Clicking a span inside a swatch works
- Only one swatch is active
- Gap clicks are ignored
- A single listener on the palette