Command Palette

Search for a command to run...

Hectal
PHASE 9Intermediate ~20 min· topic 3 of 8

Topic 9.3

Event Delegation

In one line

Event delegation means putting one listener on a parent instead of one on every child: because events bubble, the parent hears every child's event, uses event.target.closest() to find which child it was, and automatically handles children added later.

Think of it like this

A school receptionist. Instead of giving every classroom its own phone line and person, the school has one reception desk. Every call comes to the desk, the receptionist asks "which class is this about?" and passes the message on. New classrooms built next year need no new phone line: the desk already handles them. Delegation is the reception desk: one listener on the parent handles events for all of its children.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Event delegation
Handling events for many children with one listener on their parent.
Container
The parent element that holds the listener, such as a <ul> or a <table>.
closest(selector)
A method that returns the element itself or its nearest ancestor matching the selector, or null.
contains(node)
A method that tells you whether a node is inside an element (or is the element itself).
Dynamic content
Elements added to or removed from the page after it first loaded.
data attribute
A custom data-* attribute that stores information on an element, read with dataset.

Step by step

01Start with a list

Each item has a delete button. The list could have 3 items or 3,000.

index.htmlwhole filehtml
<ul id="todos">
  <li data-id="1"><span>Buy milk</span> <button data-action="delete">✕</button></li>
  <li data-id="2"><span>Walk the dog</span> <button data-action="delete">✕</button></li>
</ul>

02The per-item way (and its problem)

You could loop over every button and add a listener. That's one function per button, and any item added later has no listener at all.

app.jswhole filejavascript
document.querySelectorAll("#todos button").forEach((btn) => {
  btn.addEventListener("click", () => btn.closest("li").remove());
});

03One listener on the container

Attach one listener to the <ul>. Every click inside the list bubbles up to it.

app.jswhole filejavascript
const list = document.getElementById("todos");
list.addEventListener("click", (event) => {
  // which element was really clicked?
});

04Find the element you care about

closest walks up from wherever the click landed. If it returns null, the click wasn't on a delete button, so do nothing.

app.jswhole filejavascript
list.addEventListener("click", (event) => {
  const button = event.target.closest("[data-action='delete']");
  if (!button || !list.contains(button)) return;
  const item = button.closest("li");
  console.log("delete item", item.dataset.id);
  item.remove();
});
Find the element you care aboutdiagram
Rendering diagram…

05New items work for free

Append a new <li> with the same markup and its delete button already works. Nothing was attached to it; the <ul> handles it.

Try it yourself

  1. 1

    Add a third action

    In the first example, add a button with data-action="up" to each item (and to the innerHTML of new items). In the listener, when the action is up, move the item before its previous sibling with item.previousElementSibling?.before(item). Predict: does it work for added items without another listener?

  2. 2

    Count the listeners

    In the second example, add 50 buttons in a loop before the per-item loop. How many listeners does way 1 create? How many does way 2?

  3. 3

    Break delegation on purpose

    In the third example, change focusin to focus in the listener. Click into a box. Why does nothing turn blue?

Code & diagrams

A to-do list with one listener New tab

Item 3 was created after the listener, yet its ✓ button works. The click on the text was ignored because `closest("[data-action]")` returned `null`.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

toggled 1
added 3
toggled 3
deleted 2
items left: 2
Per-item listeners miss new items New tab

`blue` was added after the loop ran, so it never got its own listener. Only the delegated listener heard it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

per-item: red
delegated: red
delegated: blue
Delegating focus with focusin New tab

The script fires the events itself so the output is the same every time. Click into the boxes yourself: the label of the focused box turns blue, all from two listeners on the form.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

focusin from name
focusout from name

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Compare target directly

In the to-do listener, write if (event.target.dataset.action === "delete") and put an icon inside the button: <button data-action="delete"><svg>…</svg></button>.

terminal
$ the page shows
── what you'll see ──
Clicking the icon does nothing; clicking the button's edge works.

Break #2

Forget the null check

Write event.target.closest("li").remove(); with no if, then click the empty space at the bottom of the list.

terminal
$ Console
── what you'll see ──
Uncaught TypeError: Cannot read properties of null (reading 'remove')

Myth vs fact

Myth

Delegation is always faster.

Fact

It saves memory and setup time when there are many children. But the delegated listener runs for every click inside the container, so very hot events (mousemove, pointermove) delegated to a large container can cost more. Measure when it matters.

Myth

You can delegate any event.

Fact

Only events that bubble reach the container in the bubbling phase. For focus/blur use focusin/focusout; for mouseenter/mouseleave use mouseover/mouseout (or a capture listener).

Myth

Frameworks don't use delegation.

Fact

React attaches its listeners to the app's root and dispatches to your components itself. That's delegation, done for you.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    closest matches the starting element too, so event.target.closest("button") works whether the target is the button or a child of it. It's supported everywhere modern and is much faster than walking parentElement in a loop yourself.

  • ▸

    **Disabled buttons don't fire click** at all, so a delegated listener never hears them; this is usually what you want. Elements with pointer-events: none pass clicks through to whatever is underneath, and the event's target becomes that element.

  • ▸

    For keyboard accessibility, delegate on real <button> elements, not clickable <div>s. A button fires click on Enter and Space automatically, so one delegated click listener covers mouse, touch and keyboard users.

  • ▸

    When removing items, delegation also avoids leaks in older patterns that stored references to per-item listeners. With one listener on the container, removing an <li> leaves nothing behind to clean up.

Remember this

  1. 1

    Event delegation works because of bubbling (Topic 9.2). A click on any <li> bubbles up to its <ul>, so a single listener on the <ul> hears clicks on every item. Inside it, event.target says exactly where the click started. You delegate the job of listening to an ancestor, often called the container.

  2. 2

    The target is often not the element you want. In <li><span>Milk</span> <button>Delete</button></li>, a click can land on the span, the button, or the <li> itself. Use event.target.closest(selector) (Topic 8.2): it starts at the target and walks up through its ancestors until one matches, returning that element or null. const item = event.target.closest("li") finds the item, whatever part of it was clicked.

  3. 3

    Always check the result. closest returns null when the click wasn't inside a match at all (for example, on the padding of the <ul>), so start with if (!item) return;. Because closest can also match elements outside the container (an <li> that wraps your whole list in an outer menu), careful code also checks container.contains(item).

  4. 4

    Delegation's biggest win is dynamic content: elements added after the listener was attached (new to-do items, search results, rows loaded from a server) work without any extra code, because the listener is on the parent that already exists. With one listener per element, you'd have to remember to attach a listener to every new element and remove it from every deleted one.

  5. 5

    To handle several different actions from one listener, mark buttons with data attributes (Topic 8.4), like <button data-action="delete">. The listener reads event.target.closest("[data-action]"), then switches on button.dataset.action. The item's own data, like data-id, tells you which record to change. This keeps the HTML descriptive and the JavaScript in one place.

  6. 6

    Delegation needs events that bubble. click, input, change, keydown and submit do. For focus, delegate focusin/focusout instead of focus/blur; for hovering, mouseover/mouseout instead of mouseenter/mouseleave. Don't delegate everything to document: a listener there runs for every click on the page. Delegate to the nearest stable container.

Explain it without notes

01

What is event delegation and why does it work?

02

Why do you need closest in a delegated listener?

03

What are the advantages and limits of delegation?

Practice

01

Given <div id="tabs"><button data-tab="home">Home</button><button data-tab="news">News</button></div>, write one listener that logs open home or open news. Click News from code.

02

Write a delegated listener on a <table id="grid"> that logs the text of the row (<tr>) whose cell was clicked, and ignores clicks on the <thead>.

Trade-offs

  • ↔

    Delegation handles any number of current and future children with one listener, but the logic must work out which child was hit; per-element listeners are more direct to read when there are only a few fixed elements.

  • ↔

    Delegating to document catches everything (handy for global shortcuts), but runs on every event on the page and couples unrelated parts; the nearest stable container is the usual sweet spot.

Done when you can

  • I can explain why delegation works (bubbling).

  • I can find the clicked item with event.target.closest() and ignore null.

  • I can handle several actions with data-action attributes in one listener.

  • I can make elements added later work without new listeners.

  • I know to use focusin/focusout when delegating focus.