Events in the DOM do not just appear on the element user clicks. They travel through the document hierarchy in a specific lifecycle known as Propagation.
There are three distinct phases to every event:
window and travels down the DOM tree until it reaches the parent of the target.
Window to Document to Body to ... to Parent.Target to Parent to ... to Body to Window.By default, addEventListener listens to the Bubbling phase. We can force it to listen to the Capturing phase by passing true as the third argument.
const parent = document.querySelector("#parent");
const child = document.querySelector("#child");
// Capturing Listener (Third arg is true)
parent.addEventListener(
"click",
() => {
console.log("1. Parent Captured (Down)");
},
true
);
// Bubbling Listener (Default)
child.addEventListener("click", () => {
console.log("2. Child Clicked (Target)");
});
// Bubbling Listener (Default)
parent.addEventListener("click", () => {
console.log("3. Parent Bubbled (Up)");
});
// Output when clicking Child:
// 1. Parent Captured (Down)
// 2. Child Clicked (Target)
// 3. Parent Bubbled (Up)Sometimes, we don't want an event to travel all the way up to the Window.
e.stopPropagation()This method stops the event dead in its tracks. It prevents any further propagation (bubbling or capturing).
button.addEventListener("click", (e) => {
e.stopPropagation();
console.log("Clicked!");
});
// If there is a listener on the Body, it will NEVER fire.
document.body.addEventListener("click", () => console.log("Body clicked"));e.preventDefault()This does not stop propagation. It only stops the browser's default behavior for that element.
<a>): Prevents navigating to the URL.<form>): Prevents the page reload on submit.link.addEventListener("click", (e) => {
e.preventDefault(); // Stay on this page
console.log("Link clicked, but navigation blocked.");
});The Problem:
Imagine we have a dynamic shopping list with 10,000 items (<li>).
click listener to each one.
The Solution:
Instead of listening to the children, we listen to the Parent (<ul>). Because of Bubbling, every click on an <li> will eventually reach the <ul>.
We check event.target to see what was actually clicked.
// 1. Select the parent
const list = document.querySelector("#shopping-list");
// 2. Attach ONE listener
list.addEventListener("click", function (e) {
// 3. Identify the source
// e.target = The specific element clicked (e.g., the <span> text inside the li)
// e.currentTarget = The element listening (the <ul>)
// We use .closest() to handle clicks on nested elements (like an icon inside the li)
const item = e.target.closest("li");
// 4. Guard Clause: If they clicked the UL padding, ignore it
if (!item) return;
// 5. Execute Logic
console.log("You clicked item ID:", item.dataset.id);
});The Benefits:
| Concept | Direction | Default? | Usage |
|---|---|---|---|
| Capturing | Down | No | Analytics, intercepted events before target. |
| Bubbling | Up | Yes | Event Delegation. |
| Target | Static | N/A | The source element. |
| Delegation | N/A | Pattern | Handling dynamic or massive lists efficiently. |
Answer:
If our <li> contains other elements (like <b>Bold Text</b> or an <icon>), clicking that icon makes e.target the icon, not the <li>.
Using tagName === "LI" would fail if the user clicks the text inside the item.
closest("li") looks up the tree from the clicked element to find the nearest <li> ancestor, making the code robust against nested HTML.