Bubbling and Delegation
Click a button that sits inside a card, which sits inside a list. Which element did you click? In a sense, all three: the button is inside the card, and the card is inside the list. The browser takes that literally. An event starts at the element where it happened, then travels up through each ancestor, the parent, the parent's parent and so on, to the top of the page. That journey is called bubbling, and it lets one listener on a parent handle events on any number of children, including children added later.
Think of a question passed up a chain of command. The person at the front desk can answer it; if not, it goes to their manager, then to the director. Every level gets a chance to respond. Delegation, this topic's second idea, is the manager deciding to answer for the whole team, so nobody at a desk needs their own instructions. The office picture fails in one respect: in the page, the event does not stop once someone answers it. Every level hears it, all the way to the top.
How Does an Event Bubble?
To watch it, add a small practice list to the main of index.html, with one card holding a sentence and a button, and give each of the three levels its own listener at the end of js/main.js. Like the last topic's examples, it is practice, so remove it when you finish.
<ul class="demo-list">
<li class="demo-card">
<p>A card with one button.</p>
<button type="button">Like</button>
</li>
</ul>
const demoList = document.querySelector(".demo-list");
const demoCard = document.querySelector(".demo-card");
const likeButton = document.querySelector(".demo-card button");
likeButton.addEventListener("click", () => console.log("button"));
demoCard.addEventListener("click", () => console.log("card"));
demoList.addEventListener("click", () => console.log("list"));
button card list
Click the Like button and the Console shows three lines, in order: button, then card, then list. The click happened on the button, so its listener ran first, and then the same event bubbled up to the card and on to the list. Click the sentence instead, outside the button, and you get only card and list, because the event starts at the paragraph, which has no listener, and bubbles up from there.
This is a simplification, and a safe one. Strictly, the browser first passes every event down from the top of the page to the element, a phase called capturing, and only then back up. Listeners added the usual way run on the way up, so the upward journey is the one that matters in this book.
Most events bubble, including every one from the last topic. A few do not; focus, which fires when an element receives keyboard focus, is the one you are most likely to meet.
Where the Click Landed, and Where You Are Listening
A listener high up the tree needs to know two different things, and the event object keeps them apart. event.target is the element where the event started: the thing actually clicked. event.currentTarget is the element whose listener is running now. Replace the list's listener, the last line of the script, with this one to see both.
demoList.addEventListener("click", (event) => {
console.log("target:", event.target.tagName);
console.log("currentTarget:", event.currentTarget.tagName);
});
Click the Like button, and the Console shows target BUTTON and currentTarget UL. Click the sentence, and target changes to P while currentTarget stays UL. The listener is always on the list; the target is wherever the click landed.
There is a catch hiding here. If a button contains other elements, such as an icon or a word in bold, a click on that inner element makes it the target, not the button. Code that asks "was the target a button?" then gets the wrong answer. The next section's tool handles that.
Delegation: One Listener for Many
Delegation means putting one listener on a parent instead of one on each child, and working out inside it which child the event came from. The tool for that is closest. Called on an element with a CSS selector, it checks the element itself, then its parent, then each ancestor in turn, and returns the first that matches, or null if none does. So event.target.closest("button") finds the button a click was in, whether the click landed on the button or on an icon inside it.
Delegation has a second benefit that matters for this site. In the last chapter, renderProjects empties the grid and builds new cards every time it runs. A listener attached to each old card disappears with that card. A listener on the grid itself stays, because the grid is never replaced, and it works for cards that did not even exist when it was added.
The Site's Project Filter
Now the Projects page gets its filter: four buttons, All, HTML, CSS and JavaScript, that show only the projects with that tag. Add the buttons above the grid on projects.html. Each carries its tag in a data-tag attribute from the last chapter, and an aria-pressed attribute that tells screen readers whether it is the button currently pressed. All starts pressed.
<div class="filter-buttons"> <button type="button" data-tag="all" aria-pressed="true">All</button> <button type="button" data-tag="html" aria-pressed="false">HTML</button> <button type="button" data-tag="css" aria-pressed="false">CSS</button> <button type="button" data-tag="javascript" aria-pressed="false">JavaScript</button> </div>
The script has two parts. The first, below the existing lines of js/main.js, is a function that shows the projects for one tag. For all, it passes the whole array to renderProjects; for any other tag, it uses filter from Chapter 7 to keep only the projects whose tags include it.
function showTag(tag) {
let shown = projects;
if (tag !== "all") {
shown = projects.filter((project) => project.tags.includes(tag));
}
renderProjects(shown);
}
The second part is the delegated listener, on the group of buttons rather than on each one.
const filterButtons = document.querySelector(".filter-buttons");
if (filterButtons) {
filterButtons.addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button) {
return;
}
showTag(button.dataset.tag);
for (const other of filterButtons.querySelectorAll("button")) {
other.setAttribute("aria-pressed", "false");
}
button.setAttribute("aria-pressed", "true");
});
}
The guard skips the whole thing on pages without the filter. On the Projects page, every click inside the group reaches this one listener. closest finds the button that was clicked; a click on the gap between buttons finds none, and the listener stops there. Then the button's data-tag value goes to showTag, every button is marked as not pressed, and the clicked one is marked as pressed.
Finally, one rule in the stylesheet makes the pressed button look pressed. It selects buttons by the same attribute a screen reader announces, so what people see and what they hear cannot disagree.
.filter-buttons button[aria-pressed="true"] {
background: var(--color-accent);
color: var(--color-bg);
}
Save and try it. Click CSS and the grid shows only Recipe Cards, and the CSS button takes the accent colour. Click JavaScript and Budget Tracker and Weather Now appear, since Weather Now is tagged both javascript and api. Click All and all three return. In the Elements panel, the grid's cards are swapped on every click, and the aria-pressed values move from button to button.
Can You Stop an Event Bubbling?
Yes: calling event.stopPropagation inside a listener stops the event going any higher. It is rarely the right answer. A listener higher up, perhaps one someone adds next year, silently stops hearing those events, and delegation built on bubbling breaks without an error. When a listener should ignore some clicks, check event.target inside it, as the filter does, instead.
- "Every button needs its own listener." One listener on the parent handles them all, because every click bubbles up to it.
- "
event.targetis the element with the listener." That iscurrentTarget. The target is where the click landed, which may be an element inside the button; henceclosest. - "New elements need new listeners." Not with delegation. A listener on a parent that stays in the page works for children added at any time.
- The site's project list can now be filtered, with the pressed state announced to screen readers and shown on screen by the same attribute.
- Delegation is how lists with hundreds of items stay fast and simple: one listener instead of hundreds.
Knowledge Check
A button sits inside a card, which sits inside a list. Each has a click listener. In what order do they run when the button is clicked?
- List, then card, then button
- In no fixed order, different each time
- Button, then card, then list
- Only the button's own listener runs
A listener on a list logs event.target and event.currentTarget. A visitor clicks a button inside the list. What is currentTarget?
- The list, where the listener is attached
- The button, where the click landed
- The document, at the top of the page
- The card that holds the clicked button
Why does a listener on .project-grid keep working after renderProjects rebuilds the cards?
- renderProjects copies listeners to each new card
- The browser saves the old cards' listeners for later
- The grid checks the page for new cards every second
- The grid is not replaced, so its listener stays
What does event.target.closest("button") return when the click lands on an icon inside a button?
- The icon, since that is where the click landed
- The button that contains the clicked icon
- null, since the icon itself is not a button
- A list of every button on the whole page
You got correct