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.
How events bubble
Section titled “How events bubble”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 documentEvent delegation uses this: attach one listener to the parent and let clicks from children bubble up to it.
Why individual listeners do not scale
Section titled “Why individual listeners do not scale”// Fragile: breaks when cards are added dynamicallyconst 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
The delegation pattern
Section titled “The delegation pattern”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.
Reading data from the matched element
Section titled “Reading data from the matched element”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.
STO capstone: tour card delegation
Section titled “STO capstone: tour card delegation”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:
- Clicking anywhere inside a card (heading, price, button) adds the
activeclass to the card - Clicking a different card moves the
activeclass - Clicking the container between cards does nothing
- 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()andquerySelector()within the matched element is the standard pattern for interactive lists in Module 07.