completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Event delegation in JavaScript

Use one parent event listener to handle many child elements, route clicks with closest(), and support elements added later.

By the end, you can
  • 01
    Use one listenerPut one listener on a parent, and pick the job for each click that bubbles up.
  • 02
    Find the right buttonUse closest() and contains() so clicks on icons inside buttons still work.
  • 03
    Handle many buttonsName each job with a data-action attribute, and handle elements added later.

One listener for many elements

An event listener is a function that runs when something happens to an element, such as a click. Event delegation means you put one listener on a parent element, instead of one listener on every child. When a child is clicked, the listener on the parent runs. It checks which child was clicked, and then it decides what to do.

This works because of bubbling, which you met in the lesson Bubbling & capturing. A click starts on the element you clicked. Then it travels up to that element's parent, then to the parent's parent, and so on. So a listener on a parent hears clicks on everything inside it.

In this lesson, one listener on a to-do list handles every button in the list. It even handles buttons that are added later. You will see this pattern in menus, tables, inboxes, and any page made of many repeated parts.

The short version

Use delegation when many children do the same kind of job. The steps are:

  1. Put one listener on the parent.
  2. Find the clicked button with closest().
  3. Check that the button is inside your parent.
  4. Read a label on the button, such as data-action, to pick the job.

Each step is explained below.

Real-life analogyA delegated listener is a school receptionist

Imagine a school where every classroom has its own phone line. That is a lot of lines to set up. A simpler school has one front desk. Every call goes to one receptionist. The receptionist asks which room the call is for, and then passes it on. Event delegation gives the parent element the receptionist's job.

In real life: One receptionist answers every call to the school
In JavaScript: One listener on the parent
In real life: The caller says which classroom they want
In JavaScript: event.target says where the click started
In real life: The receptionist reads the sign on that room
In JavaScript: closest('[data-action]') finds the button that has a job
In real life: Calls for a new student still come to the same front desk
In JavaScript: Elements added later still send clicks to the same listener

The hard part is not adding the one listener. The hard part is the filter: the code that decides which clicks matter. The parent hears every click inside it, even clicks on empty space. So good delegated code checks each click and ignores the ones it does not need.

The delegation pattern

INTERACTIVE

Here is the pattern in a to-do list. Each row has two buttons:

  • Toggle marks the row as done, or not done.
  • Delete removes the row.

Without delegation, you add one click listener to every button. Three rows with two buttons each need six listeners. A fourth row needs two more.

With delegation, you add one listener to the ul, the list element. Say you click the small icon inside a button. The click bubbles up to the list. Then the listener does three things:

  1. It looks at the element where the click started.
  2. It finds the nearest element that has a data-action attribute.
  3. It runs the job that the attribute names.
Left: a listener on each button. The Toggle button and the Delete button each have their own listener card: 2 listeners. Right: one listener on the list. The ul has one listener card, and arrows go up from the Toggle and Delete buttons to the ul: 1 listener.a listener eachTogglelistenerDeletelistener2 listenersone on the listullistenerToggleDelete1 listener
Without delegation, every button needs its own listener. With delegation, clicks on both buttons bubble up to one listener on the ul.
One listener for the whole list
One listener on the to-do listPop out in the code editor (opens in a new tab)JavaScript
const list = document.querySelector("#todos"); list.addEventListener("click", (event) => {  if (!(event.target instanceof Element)) return;   const button = event.target.closest("[data-action]");  if (!button || !list.contains(button)) return;   const item = button.closest("[data-id]");  const action = button.dataset.action;   if (action === "toggle") item.classList.toggle("done");  if (action === "delete") item.remove();});
Live list in your browser
Listeners with delegation: 1Listeners without it: 6Clicks handled: 0
Try it yourself

Click a to-do button. Then add a new item and click its buttons too. Listeners with delegation: 1. Listeners you would need without it: 6.

This is a real list in your browser, built with plain DOM code. React does not manage it, so every click is a real browser event.

Click Add a later item in the playground. The new row's buttons work right away. This is because the listener is on the list, not on the buttons. The new row is inside the list, so its clicks bubble up to the list, just like the first rows. This is the biggest everyday reason to use delegation.

Is one listener faster?

Fewer listeners can save memory and setup time when there are many children. But the one parent listener runs for every click inside the parent. So keep its checks short and quick.

Find the button with closest()

INTERACTIVE

The first problem appears when a button has other elements inside it. A user might click the icon <span> inside the button, not the button itself. Then event.target, the element where the click started, is the span.

Say your code asks event.target.matches("button"). The method matches() checks if one element fits a CSS selector. Here the answer is false, because the target is the span, not the button.

closest(selector) solves this. It checks the element itself first. If that does not match, it checks the parent, then the parent's parent, and so on. It stops at the first match. If it reaches the top of the page without a match, it returns null.

A button with a span icon inside it. The click lands on the span: that is event.target. An arrow climbs up from the span to the button: closest with the button selector stops at the button.buttonspan (icon)climbs upclosest("button")stops hereevent.targetclick lands here
The click lands on the icon, so event.target is the span. closest("button") climbs up from there and stops at the first button.

closest() can climb above your list. So check the result with list.contains(button). The method contains() returns true when the button is inside the list.

Why matches() misses and closest() finds
matches() versus closest()Pop out in the code editor (opens in a new tab)JavaScript
list.addEventListener("click", (event) => {  if (!(event.target instanceof Element)) return;   const wrong = event.target.matches("button");  const button = event.target.closest("button");   if (!button || !list.contains(button)) return;  console.log(button.dataset.action);});
Where the click lands
Try it yourself

Choose where the click lands, then click Run to compare matches() and closest().

Real clicks often land on a span, an SVG icon, or text inside the button you meant.
A click on an icon still picks the job of its buttonPop out in the code editor (opens in a new tab)JavaScript
const clicked = "span.icon";
const command = clicked.startsWith("span") ? "delete" : "ignored";
console.log(command);

This small model starts with a click on an icon. Then it picks the job of the button around the icon. It prints delete, even though the icon itself is not a button.

How to read this table: each row is one tool. The columns say what the tool gives you and how a delegated listener uses it.

Three tools that tell you about the clicked element
ToolWhat it gives youHow delegation uses it
event.targetThe deepest element where the event started, such as an icon.Start here. It may not be the button you want.
event.currentTargetThe element whose listener is running right now.In a delegated click listener, this is the parent, such as the list.
target.closest(selector)The nearest element that fits the selector: the target itself, or an element above it.Find the button or row that the click belongs to.

Check the target before you call closest(). In browser code, event.target is not always an Element. An Element is a tag on the page, such as a button or a span. Only elements have closest(). The usual check is if (!(event.target instanceof Element)) return;.

  • instanceof Element asks: "is this value an element?"
  • return stops the listener early when the answer is no.

Name each job with a data attribute

INTERACTIVE

The parent listener needs to know which job each button does. A data-* attribute is a good place to write that down. It is an attribute you make up yourself, and its name starts with data-. The browser does nothing with it, so it is free for your code to use.

For example, data-action="delete" on a button means: "when this is clicked, delete something." In JavaScript, you read it with button.dataset.action.

Real-life analogydata-action is a label on the button

Say several packages arrive at the school front desk. Each one has a label that says which room it is for. The receptionist reads the label instead of opening every box. The delegated listener does the same: it reads a short label, and then it picks the job.

In real life: A label that says Save
In JavaScript: data-action='save'
In real life: A label that says Delete
In JavaScript: data-action='delete'
In real life: The receptionist reads the label before passing the package on
In JavaScript: The listener reads button.dataset.action

The playground below puts one listener on the demo panel, not on document (the whole page). So the listener only hears clicks inside the panel. A demo should not react to clicks on the rest of the page.

Jobs named by data attributes
One panel listener, many jobsPop out in the code editor (opens in a new tab)JavaScript
panel.addEventListener("click", (event) => {  if (!(event.target instanceof Element)) return;   const toggle = event.target.closest("[data-toggle]");  if (toggle && panel.contains(toggle)) {    document.querySelector(toggle.dataset.toggle).hidden = false;  }   const copy = event.target.closest("[data-copy]");  if (copy && panel.contains(copy)) {    output.textContent = copy.dataset.copy;  }});
Buttons in the panel
Try it yourself
Count: 0

One listener on this demo panel handles three jobs: show, copy, and count.

The listener is on the demo panel, not on document, so it only hears clicks inside the panel.

Each job in the code follows the same three steps:

  1. Find the nearest element with that data attribute.
  2. Check that the element is inside the panel.
  3. Read its data value, and do the job.

You can add more jobs this way. Keep each job small and easy to read. If a job needs a lot of its own data, give that element its own listener instead.

Elements added later

Delegation is most useful when children are created after the listener is added. For example:

  • search results
  • chat messages
  • notifications
  • table rows loaded from a server

The parent is already on the page. The children arrive later.

New children need no new listener

When you add a new element inside the parent, you do not add a listener to it. Its clicks bubble up to the parent, so the parent listener already handles them.

A listener on each button, or one on the listPop out in the code editor (opens in a new tab)JavaScript
// One listener per button: only buttons on the page right now get one.
document.querySelectorAll("button[data-action]").forEach((button) => {
  button.addEventListener("click", handleButton);
});

// One listener on the list: buttons added later bubble here too.
list.addEventListener("click", handleListClick);

The first version loops over the buttons that exist now. A button added later gets no listener. The second version adds one listener to the list, so buttons added later are handled too.

A later item joins the same listPop out in the code editor (opens in a new tab)JavaScript
const handled = ["todo-1"];
const laterItem = "todo-2";
handled.push(laterItem);
console.log(handled.join(","));

It prints todo-1,todo-2. The second id is added to the data, but no new listener is added. The one parent listener still handles both.

The ul has one listener. Below it, todo-1 was there at the start and todo-2 was added later. Arrows go up from both to the ul, so the same listener handles both.ullistenertodo-1todo-2there at startadded later
todo-2 was added after the listener. Its clicks still bubble up to the same listener on the ul.

Delegation only works when three things are true:

  • The new element is inside the parent.
  • The event bubbles.
  • No child stops the event before it reaches the parent.

The next sections show what to do when one of these is not true.

Step through one click

STEP THROUGH

Now step through the listener one line at a time. First choose where the click lands:

  • on the icon inside the Delete button,
  • on the text of the Toggle button,
  • on empty space in the list,
  • on empty space in a list that sits inside an outer row.

The replay is a model written for this lesson. The live playgrounds above run the same checks in your browser.

Step through the listener
Step 0 of 7Ready
Your turn: follow the blue line

Choose where the click lands, then step through the listener. The model runs the same checks as the live playgrounds.

Running in
  1. script
Next: line 3
const list = document.querySelector("#todos");   if (!(event.target instanceof Element)) return;   const button = event.target.closest("[data-action]");  if (!button || !list.contains(button)) return;   const item = button.closest("[data-id]");  const action = button.dataset.action;  route(action, item?.dataset.id);});
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Look at the empty-space case. The parent hears clicks it does not need. Stopping early with return is not a bug. It is the filter doing its job.

The outer-row case shows why contains() matters. A selector only says what an element looks like. It does not say which list the element belongs to.

The list sits inside an outer row that has data-action. A click on empty space in the list makes closest climb past the list to the outer row. list.contains(button) is false, so the listener ignores the click.outer rowlistclimbs uphas data-actionoutside listevent.targetempty spacelist.contains(button)false: ignore
The click is on empty space in the list, so closest() climbs past the list to the outer row. list.contains(button) is false, so the listener ignores the click.

Delegate or not?

SORT IT

Delegation is a tool, not a rule. It is a great fit when many children do the same kind of job, and when children are added later. It is a poor fit in three cases:

  • The event does not bubble.
  • A child calls stopPropagation(), which stops the event from moving up.
  • There is only one button, so a listener right on it is simpler.
Delegate or not?
  • A toolbar with 40 buttons that work the same way
  • Rows added after the page loads
  • One Save button on a settings page
  • Highlight the field that gets focus
  • React when the pointer moves onto each card
  • A child element calls stopPropagation()
  • Know when each image finishes loading
  • One close button on a pop-up
Try it yourself
0 of 8 correct

Sort each case by the kind of listener you would choose.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Which events bubble? click, input, and change bubble, so they are often delegated. focus, blur, mouseenter, mouseleave, and the load event of an element do not bubble. For those, you have three choices:

  • Use a similar event that does bubble: focusin, focusout, mouseover, or pointerover.
  • Put the listener right on the element.
  • Use a capture listener on the parent. It runs while the event travels down, before it reaches the element.
Check that the event is one the listener handlesPop out in the code editor (opens in a new tab)JavaScript
const events = ["click", "input", "change"];
const delegated = events.includes("click");
console.log(delegated);

It prints true, because click is in the list of bubbling events this listener handles. Events that do not bubble need another plan.

Use focusin to hear focus on a parentPop out in the code editor (opens in a new tab)JavaScript
const eventName = "focusin";
const bubbles = eventName === "focusin";
console.log(bubbles);

It prints true. focusin bubbles, so a parent can hear it and then find the field that got focus.

Where you will use this

Delegation appears in everyday code wherever the same markup repeats. Follow these steps for a list, a menu, or a table:

  1. Pick the smallest parent that holds all the repeated buttons and stays on the page.
  2. Listen on that parent for an event that bubbles.
  3. Check that event.target is an Element.
  4. Use closest() to find the button that was clicked.
  5. Check parent.contains(button).
  6. Read its dataset values, and call a small function for the job.
An object of jobs, looked up by namePop out in the code editor (opens in a new tab)JavaScript
const actions = {
  save(item) {
    console.log("save", item.dataset.id);
  },
  delete(item) {
    item.remove();
  },
};

list.addEventListener("click", (event) => {
  if (!(event.target instanceof Element)) return;
  const button = event.target.closest("[data-action]");
  if (!button || !list.contains(button)) return;

  const item = button.closest("[data-id]");
  const action = button.dataset.action;
  actions[action]?.(item);
});

The actions object links each job name to a function. The listener finds the button and its row. Then it calls actions[action]?.(item). The ?. skips the call when no job has that name.

Three places this pattern appears

A to-do list sends a job to the row that holds the clicked button. This small model deletes the matching row and prints the row that is left.

Delete a to-do rowPop out in the code editor (opens in a new tab)JavaScript
const todos = [
  { id: "todo-1", done: false },
  { id: "todo-2", done: false },
];
const clickedButton = { dataset: { action: "delete", id: "todo-1" } };
const index = todos.findIndex((todo) => todo.id === clickedButton.dataset.id);
if (clickedButton.dataset.action === "delete") todos.splice(index, 1);
console.log(todos.map((todo) => todo.id).join(","));

It prints todo-2. Deleting todo-1 leaves only todo-2.

A table uses the same steps for the buttons in each row. This model reads the row id, then looks up the current status of that row.

Ship one table rowPop out in the code editor (opens in a new tab)JavaScript
const rows = new Map([
  ["order-7", "Ready"],
  ["order-8", "Packed"],
]);
const clickedButton = { dataset: { action: "ship", rowId: "order-7" } };
console.log(clickedButton.dataset.action + ":" + rows.get(clickedButton.dataset.rowId));

It prints ship:Ready: the job name and the row's status together.

Menus often use one parent listener too. Here the job changes a small value instead of removing a row.

const menu = { open: false };
const clickedButton = { dataset: { action: "open-menu" } };
if (clickedButton.dataset.action === "open-menu") menu.open = true;
console.log(menu.open);

It prints true, because the open-menu job set menu.open to true.

Check which list the match belongs toPop out in the code editor (opens in a new tab)JavaScript
const matches = ["outer-list", "inner-list"];
const owner = "inner-list";
console.log(matches.includes(owner));

It prints true, because "inner-list", the list being checked, is in the matches array. A real listener does this check with container.contains(match).

In React, Vue, Svelte, and similar tools, you often use the tool's own event system instead of this exact code. The idea still helps: repeated children send an action up, and one parent decides what to do.

Common mistakes

  • "Delegation means a listener on document." Usually not. Put the listener on the smallest parent that holds the buttons. Then clicks on other parts of the page do not run it.
  • "closest() always stays inside my list." It can climb to elements above the list. Check container.contains(match).
  • "Every event can be delegated." Delegation needs bubbling. Some events need a similar event that bubbles, a capture listener, or a listener right on the element.
  • "One listener is always faster." Not always. The parent listener runs for every click inside the parent, even the clicks it ignores.
  • "stopPropagation() is harmless." It can be useful. But it stops the event before it reaches the parent listener.

How to read this table: each row is a question. The columns answer it for one listener on the parent, and for a listener on each element.

One listener on the parent versus a listener on each element
QuestionOne listener on the parentA listener on each element
How many listeners?Usually one, on a parent that stays on the page.One for each element.
What about elements added later?They work, as long as they are inside the parent.Each one needs a new listener when it is created.
Do you need to filter clicks?Yes. The parent hears every click inside it.Usually not. The listener is already on the right element.
What about events that do not bubble?Use a similar event that bubbles, such as focusin, or a capture listener.They work as usual.
A child that stops the event hides it from the parentPop out in the code editor (opens in a new tab)JavaScript
const parentSawClick = true;
const childStopped = true;
console.log(parentSawClick && !childStopped);

It prints false. When a child stops the event, the parent's delegated listener never sees that click.

Practice

5 EXERCISES
Exercise 1 · Warm-upPredict the job name

Read the small model and predict what it prints.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const target = "span.icon";
const button = target.includes("span") ? "delete" : null;
console.log(button ?? "ignored");

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upCount the listeners without delegation

    Use the listener counts from the first playground.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const items = 4;
    console.log(items * 2);
    console.log(1);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWrite the safe checks

      Write the three checks that make a delegated click listener safe.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      function handleClick(event) {
        // 1. Check that the target is an Element.
        // 2. Find [data-action].
        // 3. Ignore matches outside list.
      }
        Exercise 4 · PracticeFind the bug

        Explain why this works when you click the button text, but not when you click an icon inside the button. Then compare with the solution.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        list.addEventListener("click", (event) => {
          if (event.target.matches("button")) {
            event.target.closest("li").remove();
          }
        });
          Exercise 5 · ChallengeJoin the job name and the id

          Predict the text a delegated listener would pass to a job function.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          const action = "toggle";
          const id = "todo-3";
          console.log(action + ":" + id);

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            7 QUESTIONS
            Event delegation quiz · 7 questionsScore: first tries count
            1. Question 1 of 7What does event delegation need, for clicks to work?

              Choose an answer to see the explanation.

            2. Question 2 of 7Buttons have icons inside them. Why use closest() instead of only matches()?

              Choose an answer to see the explanation.

            3. Question 3 of 7What does this print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const target = "span.icon";
              const matchesButton = target === "button";
              const closestButton = "button[data-action=delete]";
              console.log(matchesButton);
              console.log(closestButton.includes("delete"));

              Choose an answer to see the explanation.

            4. Question 4 of 7Why check container.contains(match) after closest()?

              Choose an answer to see the explanation.

            5. Question 5 of 7Which attribute is best for a short job name such as save, delete, or toggle?

              Choose an answer to see the explanation.

            6. Question 6 of 7This code counts listeners with delegation. What does it print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              let attached = 1;
              const itemsBefore = 3;
              const itemsAfter = itemsBefore + 2;
              console.log(attached);
              console.log(itemsAfter);

              Choose an answer to see the explanation.

            7. Question 7 of 7focus does not bubble. Which similar event is usually delegated instead?

              Choose an answer to see the explanation.

            Key takeaways

            • Event delegation puts one listener on a parent. That listener handles events that bubble up from the children.
            • Use event.target to start, closest() to find the button, and contains() to check that the button is inside the parent.
            • A data-action attribute names the job of each button, so one listener can handle many jobs.
            • Elements added later work too, as long as they are inside the parent.
            • Delegation needs bubbling. Use another plan for events that do not bubble, or when a child stops the event.

            Final definition: Event delegation means handling the events of many child elements, now and in the future, with one listener on a parent. The listener checks where each event started, and then it picks the right job.

            Up next: Default actions.

            CompleteFrontend Clear concepts. Working examples.