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.

parentone handler herechild Achild Bchild Cevents bubble up to the shared handler
Delegation flips the usual setup. Instead of N handlers on N children, one handler on the parent catches events bubbling up from all of them.

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:

interactiveOne handler on the table highlights any clicked 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.

TABLE (handler here)TDSTRONGclick → target = STRONG
event.target is the deepest element clicked. A click on the inner STRONG makes STRONG the target, even though the handler lives on TABLE.

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:

  1. elem.closest(selector) returns the nearest ancestor (including elem itself) that matches the CSS selector, or null if none matches. Here it climbs from the clicked element until it hits a <td>. Click the <strong> and closest('td') returns its parent cell. Click the cell directly and it returns the cell itself.
  2. If the click was outside any <td> (say, on the header row), closest returns null and we bail out.
  3. In a page with nested tables, event.target could 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.
  4. 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:

interactiveOne menu handler routes clicks to methods by data-action

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.

elem.onclick = this.onClick
this → the <div> element
this[action] → undefined
elem.onclick = this.onClick.bind(this)
this → the Player object
this[action] → the method
With and without bind: what this points to when the browser calls the click handler.

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:

  1. A custom attribute on an element describes what it should do.
  2. A document-wide handler watches for events and, when one lands on an element carrying that attribute, runs the corresponding action.
documentaddEventListener(‘click’, …)[data-counter][data-toggle-id]
A behavior lives at the document level. Any element that later gains the attribute is covered, without new JavaScript.

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.

interactiveNew elements pick up the behavior with no extra JS

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:

interactivedata-toggle-id: one handler, any number of toggles

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:

  1. Put a single handler on a container.
  2. In the handler, inspect event.target (often via closest) to find the element you care about.
  3. If the event started inside a relevant element, handle it.

Benefits:

And the limits: