HAP juggling multiple robot cards, representing managing many event listeners at once

Station 4: Bubbling Up

Event bubbling and delegation

Welcome to Station 4! After Station 3, I had a working robot card page — cards, a detail view, a form. I felt confident. So I added more robot cards. Then more. Then I wrote a loop to add listeners to every single card.

It worked! Until I added a card dynamically. That new card did nothing when I clicked it. No listener. I had wired up the old cards but completely missed the new one.

I tried fixing it by adding listeners inside the loop that created new cards. It got complicated fast. Something felt wrong about the approach.

That's when I discovered event bubbling — and realized the browser had been doing something useful all along that I had never noticed.

Let me show you how one listener on a parent container replaced a dozen listeners on individual cards... 🟠

🔬 Try it yourself: See Event Delegation in Action →

Quick Reference

Event Bubbling and Delegation →

What You'll Learn at This Station

HAP's Discovery: I thought event listeners had to live on the exact element I wanted to watch. Turns out the browser tells every ancestor about every event — all the way up to the window. That behavior has a name: bubbling. And once I understood it, I stopped putting listeners on individual cards and started putting one listener on their container instead. That technique is called delegation, and it solved my dynamic content problem completely.

🫧 Bubbling

Event travels up

When an event fires on an element, the browser fires it on every ancestor too — child, parent, grandparent, all the way to the window. The event "bubbles" up the tree.

🎯 Delegation

Listen on the parent

Instead of adding a listener to every child element, add one listener to a parent container. Any click on any child bubbles up and triggers it — including dynamically added children.

🔍 .closest()

Find the right ancestor

event.target is the exact element clicked — often a span or icon inside the card, not the card itself. .closest() walks up the DOM to find the nearest matching ancestor.

HAP surrounded by tangled code with an oops expression

HAP's Confession:

  • I added a click listener to every card inside a forEach loop. It worked great — until I added a card dynamically. That new card did nothing when clicked. My loop had already finished running.
  • My first delegation attempt seemed broken. I clicked a card and nothing happened. Then I logged event.target and saw it was the <span> inside the card, not the card itself. I had been checking event.target.classList.contains('robot-card') and it was never true. I needed .closest().
  • I thought bubbling was a bug for a while. I kept getting events firing in places I did not expect. Once I learned the behavior was intentional and had a use, I completely changed how I thought about event handling.

How Event Bubbling Works

When I click any element, the browser does not stop there. It fires the event on that element first, then fires it on the parent, then the grandparent, and keeps going all the way up to the window. Every ancestor in the chain gets a turn.

Where It Starts

The element you actually clicked is called the event target. The event fires there first. If you click a <span> inside a <div>, the span is the target.

Where It Goes

After the target, the event travels up — parent, grandparent, great-grandparent, all the way to document and window. Any element with a matching listener along the way will trigger it.

event.target vs. event.currentTarget

event.target is always the element that was originally clicked. event.currentTarget is the element where the current listener is attached. When using delegation, these are different.

Stopping the Bubble

event.stopPropagation() halts bubbling at the current element. I learned to use this carefully — stopping propagation can break delegation patterns elsewhere on the page.

The bubbling path:
// When you click the span inside a card...
// The event fires on the span first, then bubbles UP:

// 1. span.robot-name (where the click happened)
// 2. div.robot-card (parent)
// 3. div.card-container (grandparent)
// 4. main
// 5. body
// 6. html
// 7. document
// 8. window

// Every ancestor gets a chance to handle the event.
// That's bubbling.

A single click on a span.robot-name triggers listeners on every ancestor all the way to window. The container's listener fires because the event bubbles up to it.

Grace Hopper:

"In Station 2, I told you that event.target and event.currentTarget differ when event propagation is involved. Now you see why that distinction matters. When a delegated listener fires on the container, event.target is the element that was clicked — which may be deep inside the container — while event.currentTarget is the container itself."

Listener Per Card vs. Delegation

Here is the difference between the two approaches. Both work for static cards. Only delegation handles dynamic ones.

The problem: a listener on every card
// The approach I started with — a listener on every card
const cards = document.querySelectorAll('.robot-card');

cards.forEach(function(card) {
  card.addEventListener('click', function(event) {
    showDetail(card);
  });
});

// This works for the cards that existed when the page loaded.
// But when I added a new card dynamically later...

const newCard = document.createElement('div');
newCard.classList.add('robot-card');
newCard.textContent = 'Unit 99';
cardContainer.appendChild(newCard);

// ...this new card has NO listener. Clicking it does nothing.
// I would have to add a listener every time I create a card.

This approach breaks the moment a card is added after the loop runs. There is no way for the existing listeners to cover new elements — each new card needs its own listener added at creation time, which makes the code increasingly tangled.

The solution: one delegated listener on the container
// One listener on the parent container handles all cards —
// including ones that don't exist yet.

const cardContainer = document.querySelector('.card-container');

cardContainer.addEventListener('click', function(event) {
  // event.target is whatever was actually clicked.
  // It might be the card div, or a span inside the card.

  // .closest() walks UP the DOM from event.target
  // and finds the nearest ancestor matching the selector.
  const card = event.target.closest('.robot-card');

  // If the click happened outside any card, card is null.
  if (!card) return;

  showDetail(card);
});

// Now add a new card any time — it works automatically.
const newCard = document.createElement('div');
newCard.classList.add('robot-card');
newCard.textContent = 'Unit 99';
cardContainer.appendChild(newCard);
// Clicking newCard triggers the container's listener. ✓

The container's listener exists before any cards are added and keeps working after new ones appear. Because clicks bubble up from any card — new or old — the container always catches them.

The .closest() Pattern

My first delegation attempt looked right but did not work. The problem was event.target. When I clicked the robot's name text, the target was the <span> inside the card — not the card itself. Checking for the card's class on event.target always came back false.

Why event.target is not always the card:
// Why .closest() matters

// Suppose a robot card looks like this:
// <div class="robot-card">
//   <span class="robot-name">Unit 42</span>
//   <span class="robot-status">Online</span>
// </div>

cardContainer.addEventListener('click', function(event) {
  console.log(event.target);
  // If I clicked on "Unit 42" text:
  //   event.target → <span class="robot-name">Unit 42</span>
  //   NOT the card div!

  // Without .closest(), this check would fail:
  if (event.target.classList.contains('robot-card')) {
    // Never true when clicking on a child span
  }

  // With .closest(), it walks UP until it finds the card:
  const card = event.target.closest('.robot-card');
  // Returns <div class="robot-card"> whether I clicked
  // the card itself OR any element inside it.
});

.closest(selector) starts at event.target and walks up the DOM tree until it finds a matching element or runs out of ancestors. It returns null if nothing matches — which is how I guard against clicks on the container itself that miss all cards.

🟠 The Guard Clause

Every delegated listener I write now starts with const card = event.target.closest('.robot-card'); if (!card) return;. If the click landed outside any card (on the container's padding, for example), .closest() returns null and the early return stops the handler from running with bad data. That one-liner saved me from several confusing bugs.

Event Bubbling and Delegation Quick Reference

1

Events Bubble Up

A click on any element fires that event on the element, then its parent, then grandparent, all the way to window. Any ancestor with a matching listener will respond.

2

Delegation: One Listener on the Parent

Attach one listener to a container instead of one to each child. Clicks on any child bubble up and trigger the container's listener — including clicks on dynamically added children.

3

Use .closest() to Identify the Card

event.target.closest('.robot-card') walks up the DOM from the clicked element and returns the nearest ancestor matching that selector. Returns null if no match is found.

4

Always Guard Against Null

After calling .closest(), check the result before using it: if (!card) return;. Clicks on the container background return null and should be ignored.

I now have a fully interactive robot card page — clickable cards, view switching, a working form, and delegated events that handle dynamically added cards without any extra wiring. The page feels alive. At Station 5, I put all of these patterns together into one complete mini app. 🟠

Quick Reference

Event Bubbling and Delegation →