Event delegation
Bubbling has a payoff, and this is it. Because an event rises from the element you clicked up through every ancestor, you can catch it in one place near the top instead of attaching a separate handler to each element below. That single pattern, called event delegation, is one of the most reused tricks in DOM programming.
The core idea fits in a sentence: when many elements need the same kind of handling, put one handler on their common ancestor rather than one handler on each element. Inside that handler you read event.target to find out which element the event actually started on, then act accordingly.
To make it concrete, here is a star chart, a grid of constellations laid out as a table.
Try it. Click any cell — including right on the bold text inside one — and the single handler on the table highlights the whole cell:
The markup is an ordinary table:
<table>
<tr>
<th colspan="3">Star Chart: Constellation, Brightest Star, Season</th>
</tr>
<tr>
<td class="ori"><strong>Orion</strong><br>Rigel<br>Winter</td>
<td class="lyr">...</td>
<td class="cyg">...</td>
</tr>
<tr>...2 more lines of this kind...</tr>
<tr>...2 more lines of this kind...</tr>
</table>
This table holds 9 cells. It could hold 99 or 9999 and nothing below would change.
The task: highlight a cell (<td>) when it is clicked.
Rather than hang an onclick on every <td> (there could be a lot of them), we register one catch-all handler on the <table>. It reads event.target to learn which cell was clicked and highlights that one.
let selectedTd;
table.onclick = function(event) {
let target = event.target; // where was the click?
if (target.tagName != 'TD') return; // not on a TD? Then it's none of our business
highlight(target); // highlight it
};
function highlight(td) {
if (selectedTd) { // remove the previous highlight, if there is one
selectedTd.classList.remove('highlight');
}
selectedTd = td;
selectedTd.classList.add('highlight'); // highlight the new td
}
This code is blind to how many cells the table has. Add or remove <td> elements whenever you like and the highlighting keeps working, because the handler lives on the parent, not the cells.
The nested-element problem
There is a catch. A click does not always land directly on the <td>. It can land on something inside it.
Look again at a cell’s contents. There are nested tags in there, such as <strong>:
<td>
<strong>Orion</strong>
...
</td>
If the click happens on that <strong>, then <strong> becomes event.target, not the <td>. Our first version would see tagName == 'STRONG', hit the early return, and highlight nothing.
So the handler needs to take whatever event.target is and walk upward to find the enclosing <td>, if any. Here is the sturdier version:
table.onclick = function(event) {
let td = event.target.closest('td'); // (1)
if (!td) return; // (2)
if (!table.contains(td)) return; // (3)
highlight(td); // (4)
};
Walking through the numbered lines:
elem.closest(selector)returns the nearest ancestor (includingelemitself) that matches the CSS selector, ornullif none matches. Here it climbs from the clicked element until it hits a<td>. Click the<strong>andclosest('td')returns its parent cell. Click the cell directly and it returns the cell itself.- If the click was outside any
<td>(say, on the header row),closestreturnsnulland we bail out. - In a page with nested tables,
event.targetcould sit inside a<td>that belongs to a different table.table.contains(td)confirms the found cell actually lives inside our table before we act on it. - Only then do we highlight.
The result is fast, memory-light highlighting that does not care how big the table grows.
Delegation for actions in markup
Highlighting is one use. Delegation also shines when you want markup itself to declare what should happen.
Say you are building a media player toolbar with buttons “Play”, “Pause”, “Stop”, and you have an object whose methods are play, pause, stop. How do you connect a button to its method?
The obvious route is one handler per button. The tidier route is one handler for the whole menu plus a data-action attribute on each button naming the method to run:
<button data-action="play">Click to Play</button>
The handler reads that attribute and calls the matching method. Here is the full working piece:
<div id="menu">
<button data-action="play">Play</button>
<button data-action="pause">Pause</button>
<button data-action="stop">Stop</button>
</div>
<script>
class Player {
constructor(elem) {
this._elem = elem;
elem.onclick = this.onClick.bind(this); // (*)
}
play() {
alert('playing');
}
pause() {
alert('pausing');
}
stop() {
alert('stopping');
}
onClick(event) {
let action = event.target.dataset.action;
if (action) {
this[action]();
}
};
}
new Player(menu);
</script>
Here it is running — the alerts are swapped for a visible log so you can watch each click resolve to a method. Try adding a data-action="mute" button in the code and giving the class a matching method:
Notice this.onClick.bind(this) at line (*). That bind matters. Without it, when the browser invokes the handler, this inside onClick would point at the DOM element (elem), not the Player instance, and this[action]() would look for play/pause/stop on the <div>, where they do not exist.
So what does delegation buy you here?
You could reach the same end with classes like .action-save and .action-load, but a data-action attribute reads more clearly as data rather than styling, and it stays available to CSS selectors ([data-action="save"]) when you want it.
The “behavior” pattern
Delegation can also attach behaviors to elements declaratively, through custom attributes and classes. The idea is to extend HTML with your own vocabulary of actions.
The pattern has two halves:
- A custom attribute on an element describes what it should do.
- A document-wide handler watches for events and, when one lands on an element carrying that attribute, runs the corresponding action.
Behavior: Counter
Here the data-counter attribute grants a behavior — “increase the value on click” — to any button:
Counter: <input type="button" value="1" data-counter>
One more counter: <input type="button" value="2" data-counter>
<script>
document.addEventListener('click', function(event) {
if (event.target.dataset.counter != undefined) { // if the attribute exists...
event.target.value++;
}
});
</script>
Click a button and its value goes up. The buttons are not the point; the approach is. You can drop in as many data-counter inputs as you want, at any moment, and each one gains the behavior for free. In effect you have extended HTML with an attribute that means “this is a counter.”
The demo below proves the “for free” claim: the document-level handler is registered once, yet the Add counter button injects brand-new inputs that already respond to clicks — no new listener is wired to them.
Behavior: Toggler
Another behavior. Clicking an element with a data-toggle-id attribute shows or hides the element whose id matches that value:
<button data-toggle-id="subscribe-mail">
Show the subscription form
</button>
<form id="subscribe-mail" hidden>
Your mail: <input type="email">
</form>
<script>
document.addEventListener('click', function(event) {
let id = event.target.dataset.toggleId;
if (!id) return;
let elem = document.getElementById(id);
elem.hidden = !elem.hidden;
});
</script>
The attribute is written data-toggle-id in HTML and read as dataset.toggleId in JavaScript — the dashes turn into camelCase. To give any element a show/hide toggle, you no longer write JavaScript. You add the attribute and point it at a target id. The one document-level handler makes it work for every such element on the page, including ones added long after load.
Two buttons, two targets, one handler — watch each button toggle only the panel its attribute names:
Behaviors also stack: a single element can carry several behavior attributes and pick up all of them. Taken together, the pattern is a clean alternative to sprinkling tiny script fragments across a page.
Summary
Event delegation leans entirely on bubbling. One handler high up in the tree catches events from everything beneath it.
The recipe:
- Put a single handler on a container.
- In the handler, inspect
event.target(often viaclosest) to find the element you care about. - If the event started inside a relevant element, handle it.
Benefits:
And the limits: