Event delegation means handling an event for many elements with one listener on a parent they share, instead of a listener on each. A list of 500 items gets one click listener on the <ul>, not 500 on the <li>s. When a click arrives, the listener looks at where it started and works out which item it was meant for.
Picture an office building with one front desk. Instead of someone waiting at every office door, the desk takes in every parcel and reads the label to see whose it is. An office that opens next month gets its parcels from the first day, with nobody new to hire.
It works because most events bubble: after an event fires on the element where it happened, called its target, it fires again on that element's parent, then on the parent's parent, and so on up to document and window. Here's a click on the word One in <ul><li><span>One</span></li><li>Two</li></ul>, with a listener on the <ul> only:
| Step | The event reaches | What happens |
|---|---|---|
| 1 | <span> | This is where the click happened, so the <span> is event.target. There's no listener here. |
| 2 | <li> | The event bubbles up to the parent. No listener here either. |
| 3 | <ul> | The listener runs. event.target is still the <span>, and event.currentTarget is the <ul>, the element the listener is on. |
| 4 | <body>, <html>, document, window | It keeps bubbling, unless a listener calls event.stopPropagation(). |
The target is often something inside an item, like this <span>, an icon, or bold text, so the listener's job is to work out which item the target sits in. And because the listener is on the parent, items added later are covered with no extra work: a new <li> is inside the <ul>, so its clicks bubble to the same listener.
A few events don't bubble, such as focus, blur, mouseenter, and mouseleave, so this doesn't work for them as it is; focusin and focusout are versions of focus and blur that do bubble. You meet delegation in long lists, tables, menus, and accordions. React uses it too: it attaches its listeners to the root element your app renders into (before version 17, to document), not to each element.
Write delegate(root, type, selector, handler). Its arguments, in that order, are the container, the event type (such as "click"), a CSS selector for the items, and the function to call.
It adds one type listener to root. When an event arrives, find the closest element matching selector, starting at event.target and going up. If there is one and it's inside root, call handler with this set to that element and the arguments (event, matchedElement). Ignore the event otherwise. Items added inside root after delegate was called work too.
Return a function that removes the listener.
// <ul id="list"><li><span>One</span></li><li>Two</li></ul>
const list = document.getElementById("list");
const off = delegate(list, "click", "li", function (event, item) {
item.classList.toggle("done"); // this === item too
});
list.querySelector("span").click(); // the first <li> gets the class "done"
const three = document.createElement("li");
list.append(three);
three.click(); // added later, and it gets "done" too
off();
three.click(); // nothing: the listener is gone
const handler = spy();
delegate(document.getElementById("list"), "click", "li", handler);
fire(document.getElementById("first"), "click");
expect(handler).toHaveBeenCalledTimes(1);
const first = document.getElementById("first");
const handler = spy();
delegate(document.getElementById("list"), "click", "li", handler);
fire(first.querySelector(".label"), "click");
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.calls[0].args[1]).toBe(first);
const first = document.getElementById("first");
const handler = spy();
delegate(document.getElementById("list"), "click", "li", handler);
const event = fire(first.querySelector(".label"), "click");
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.calls[0].this).toBe(first);
expect(handler.calls[0].args[0]).toBe(event);
const list = document.getElementById("list");
const handler = spy();
delegate(list, "click", "button.remove", handler);
fire(document.querySelector(".label"), "click");
fire(list, "click");
expect(handler).not.toHaveBeenCalled();
const list = document.getElementById("list");
const handler = spy();
delegate(list, "click", ".item", handler);
fire(list, "click");
expect(handler).not.toHaveBeenCalled();
const list = document.getElementById("list");
const handler = spy();
delegate(list, "click", "li", handler);
const item = document.createElement("li");
item.textContent = "Three";
list.append(item);
fire(item, "click");
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.calls[0].this).toBe(item);
const handler = spy();
const off = delegate(document.getElementById("list"), "click", "li", handler);
off();
fire(document.getElementById("first"), "click");
expect(handler).not.toHaveBeenCalled();
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Event delegation helper. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.