Events, bubbling and delegation
An event travels down the tree and back up. Understanding the return journey is what lets one listener handle a thousand elements.
Click a button inside a list item inside a list inside a main element, and the event does not just happen at the button. It travels: down from the document to the button, which is the capture phase; it arrives, which is the target phase; and then it travels back up, which is the bubble phase. Listeners fire at every node along the way that has one.
By default addEventListener registers for the bubble phase. Pass { capture: true } and it fires on the way down instead. Almost all code uses bubbling, and capture exists for the cases where you need to intercept something before it reaches its target.
Inside a handler, event.target is where the event originated, the button you actually clicked, and event.currentTarget is the node whose listener is running. On the button’s own listener they are the same. On a listener attached to the list, target is still the button and currentTarget is the list. Confusing these two is the source of most delegation bugs, and the log in the instrument below shows both changing as the event travels, which is the fastest way to make it stick.
That distinction is what makes delegation possible. Instead of attaching a listener to each of two hundred list items, attach one to the list and ask event.target.closest('li') which item was hit. One listener instead of two hundred. It uses less memory, it is faster to set up, and, the reason it actually matters, it works for items added later, because the listener is on the parent and the parent was always there. The delegation panel in the instrument has rows added after binding, and in the per-row version they are silently dead.
One click, nine visits
documentcapturemainul.productsbubbleli[data-id="7"]button#buybubblePredict the firing order
nothing predicted yet
| # | phase | event.currentTarget | event.target |
|---|---|---|---|
| Nothing has run yet. | |||
propagation
Runs the full path: four capture visits, the target, four bubble visits.
default action
default action: the form submits and the page navigates to /cart.
Click a row. Then add one and click that.
listeners
1
rows
200
rows with no handler
0
ul.addEventListener('click', (e) => {
const row = e.target.closest('li');
if (!row || !ul.contains(row)) return;
open(row.dataset.id);
});Delegation works because the event passes through the ul on its way up, carrying target with it. One listener covers every row that exists now and every row rendered after, which is why it survives a re-render, a filter, an infinite scroll, and a row inserted by somebody else's script.
Pick the listeners, choose capture or bubble for each, optionally predict the order, then click Buy. The token goes down first: capture runs before the target, which is the half most people have never seen fire.
Then the two methods everyone confuses. preventDefault() stops the browser’s default action, following a link, submitting a form, checking a checkbox. It does not stop the event travelling. stopPropagation() stops the event travelling to further nodes. It does not stop the default action. They are orthogonal, and "I called stopPropagation and the form still submitted" is a bug report that makes complete sense once you know that.
A word of caution about stopPropagation, since it is over-used: it is invisible action at a distance. Some other component’s listener that worked yesterday now does not fire, and nothing in that component explains why. Prefer checking whether the event is relevant to you over preventing everyone else from seeing it.
Worth reading
- MDN: Event bubbling ↗, Covers the three phases and delegation with runnable examples.
You should now be able to
- Describe the capture, target and bubble phases
- Use delegation to handle a list with one listener
- Tell target from currentTarget without hesitating
- Say precisely what stopPropagation and preventDefault each do not do
Loading…