Skip to content

Event Delegation

If you have a list of ten tour cards and each needs a click handler, you have two options: attach ten listeners (one per card) or attach one listener to the container. Event delegation is the second approach, and it is almost always better.

When you click a button, the click event fires on the button — then fires again on its parent, then its grandparent, all the way up to document. This is event bubbling.

click fires on <button>
↑ bubbles to .tour-card
↑ bubbles to .tours-grid
↑ bubbles to main
↑ bubbles to body
↑ bubbles to document

Event delegation uses this: attach one listener to the parent and let clicks from children bubble up to it.

// Fragile: breaks when cards are added dynamically
const cards = document.querySelectorAll('.tour-card');
cards.forEach(card => {
card.addEventListener('click', handleCardClick);
});

Problems:

  • If tour cards are added to the page after this code runs, the new cards have no listener
  • Ten cards means ten listener objects in memory
  • When cards are removed and re-rendered, the listeners must be manually reattached

One listener on the container, using event.target.closest() to find which card was clicked:

const container = document.querySelector('.tours-grid');
container.addEventListener('click', (event) => {
const card = event.target.closest('.tour-card');
if (!card) return; // click was outside any tour card
console.log('Tour card clicked:', card);
});

event.target.closest('.tour-card') walks up the DOM from the clicked element until it finds a .tour-card ancestor (or itself). If the click was on the heading inside a card, closest finds the card. If the click was on the container background (between cards), closest returns null and the guard returns early.

Once you have the card element, use querySelector within it to access its contents, or dataset to read data-* attributes:

container.addEventListener('click', (event) => {
const card = event.target.closest('.tour-card');
if (!card) return;
const title = card.querySelector('h3').textContent;
const category = card.dataset.category;
console.log(`Clicked: ${title} (${category})`);
});

This pattern — delegate to parent, closest to identify, querySelector within — is the standard approach for interactive lists throughout Module 07.

Attach one delegated listener to the container:

const toursContainer = document.querySelector('.tours-grid');
if (toursContainer) {
toursContainer.addEventListener('click', (event) => {
const card = event.target.closest('.tour-card');
if (!card) return;
// Remove active class from all cards
toursContainer.querySelectorAll('.tour-card').forEach(c => {
c.classList.remove('active');
});
// Add active class to the clicked card
card.classList.add('active');
console.log('Category:', card.dataset.category);
});
}

Verify that:

  1. Clicking anywhere inside a card (heading, price, button) adds the active class to the card
  2. Clicking a different card moves the active class
  3. Clicking the container between cards does nothing
  4. Cards added to the DOM later are handled automatically — no new listeners needed
  • Events bubble up the DOM — a click on a child fires on every ancestor.
  • Event delegation puts one listener on a parent and uses event.target.closest(selector) to identify which child was the source.
  • The guard pattern if (!card) return; handles clicks that land outside any valid target.
  • Delegation works for dynamically rendered content — new elements are automatically covered.
  • Combining closest() and querySelector() within the matched element is the standard pattern for interactive lists in Module 07.