RizTech Academy logo
RizTech Academy
The DOMLesson 4 of 725 min

Event delegation and bubbling

You have a list of orders, each with a delete button. You attach a listener to every button. Then a new order arrives, you add a row — and its button does nothing.

That bug, and its fix, is this lesson.

Events travel

When you click a span inside an li inside a ul, the browser does not fire one event. It fires the same event at every level, from the inside out:

span.addEventListener('click', () => console.log('span'));
li.addEventListener('click', () => console.log('li'));
ul.addEventListener('click', () => console.log('ul'));

span.click();
span
li
ul

This is bubbling, and it is the default. The event starts at the deepest element and rises through every ancestor to document.

There is an earlier phase too. Pass true as a third argument and your listener runs on the way down:

ul.addEventListener('click', () => console.log('ul capture'), true);
span.addEventListener('click', () => console.log('span bubble'));

span.click();
ul capture
span bubble

Capturing is rare — you will go years without needing it. Bubbling is what everything below relies on.

The fix: listen on the container

Because the event passes through the parent, the parent can handle it.

const list = document.querySelector('#orders');

list.addEventListener('click', (event) => {
  const row = event.target.closest('.order');
  if (!row) return;

  console.log(row.dataset.id);
});

One listener, on the list. Click any row — including rows that did not exist when the listener was attached — and it works.

This is event delegation, and it is the standard way to handle lists.

Three parts, each doing a job:

event.target is what was actually clicked, which may be a span deep inside the row rather than the row itself.

.closest('.order') walks up from there until it finds a matching ancestor. That is what turns "the user clicked some text" into "the user clicked this order".

if (!row) return; handles clicks that landed in the list but not on a row — padding, a gap, a heading. closest returns null when nothing matches, and without this guard you get Cannot read properties of null.

Why it is better, not just shorter

New elements work automatically. Nothing to wire up when a row is added. This is the bug from the opening, and delegation is why it cannot happen.

One listener instead of hundreds. A 500-row table with a button each is 500 listeners, each holding a closure — the accumulation cost from the last lesson. Delegation makes it one.

Removed rows clean up after themselves. No listener was attached to the row, so nothing is left behind when it goes.

Telling the buttons apart

A row usually has more than one action:

list.addEventListener('click', (event) => {
  const row = event.target.closest('.order');
  if (!row) return;

  const action = event.target.dataset.action;

  if (action === 'delete') {
    row.remove();
  } else if (action === 'paid') {
    row.classList.toggle('is-paid');
  }
});
<li class="order" data-id="1">
  Priya
  <button type="button" data-action="paid">Paid</button>
  <button type="button" data-action="delete">Delete</button>
</li>

The data-action attribute is the handle. Adding a third button means adding a branch, not a listener.

Note event.target.dataset.action reads from what was clicked, while the row came from closest. If a button contains an icon, the target may be the icon — so event.target.closest('[data-action]') is the robust version, for exactly the same reason as the row lookup.

stopPropagation, and when not to

span.addEventListener('click', (event) => {
  event.stopPropagation();
});

The event stops there and no ancestor sees it.

This is mostly a bad idea. It makes an element quietly break things attached further up — a delegated handler, a "close when you click outside" dropdown, analytics. The symptom is one part of the page not working with no error anywhere, and the cause is a stopPropagation somebody added three months ago for an unrelated reason.

Prefer a check in the outer handler — "was this one of mine?" — which is what closest already gives you.

The legitimate uses are narrow: a genuinely self-contained widget, or a nested interactive control inside a clickable row, where a click on the inner control must not also trigger the row.

Events that do not bubble

Event Bubbles?
click, input, change, submit, keydown Yes
focus, blur No
mouseenter, mouseleave No
scroll No, except on document

You cannot delegate focus and blur, which catches people building form validation. Use focusin and focusout, which are the same events and do bubble. mouseover and mouseout bubble where mouseenter and mouseleave do not.

Check your work

A click on a nested span fires handlers on the span, then the li, then the ul — inside out. That is bubbling.

A capture listener runs before the bubble listeners, giving ul capture then span bubble.

Delegation means one listener on the container, using event.target .closest(...) to find the row.

closest returns null when nothing matches, so the if (!row) return; guard is required — without it, a click on the container's padding throws.

Delegation handles elements added later, because the listener was never on them.

data-action distinguishes the buttons, and reading it from event.target.closest('[data-action]') survives an icon inside the button.

stopPropagation breaks things attached further up, silently. Prefer a closest check in the outer handler.

focus and blur do not bubble and cannot be delegated. Use focusin and focusout.

Practice

  1. Put a click listener on three nested elements and click the innermost. Note the order.
  2. Add a capturing listener to the outermost and confirm it runs first.
  3. Write the bug first. Attach a listener to each of three delete buttons. Then add a fourth row with JavaScript and confirm its button does nothing.
  4. Replace all of it with one delegated listener and confirm the new row now works.
  5. Click the container's padding, outside any row, and confirm your guard stops the error. Then remove the guard and read the error.
  6. Add data-action to two buttons per row and branch on it. Then add a third action and notice you did not touch the listener.
  7. Put an icon element inside a button and click exactly on the icon. Watch event.target.dataset.action come back undefined, then fix it with closest('[data-action]').
  8. Break something with stopPropagation. Add a delegated handler on the list and a stopPropagation on one row's inner element. Confirm that row stops working, with no error.
  9. Try to delegate blur from a container and watch it not fire. Switch to focusout and watch it work.
  10. Harder. Build an order list where each row has Paid and Delete buttons, using exactly one listener on the container. Deleting must work on rows added after load, Paid must toggle a class, and a click on the list's own padding must do nothing. Then add a row counter that stays correct through additions and deletions — and keep the count in a variable rather than reading children.length, so that it is your state driving the page rather than the page being your state.

Next: forms — reading what somebody typed, and validating it before you trust it.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship