Event delegation in JavaScript
Use one parent event listener to handle many child elements, route clicks with closest(), and support elements added later.
- 01Use one listenerPut one listener on a parent, and pick the job for each click that bubbles up.
- 02Find the right buttonUse
closest()andcontains()so clicks on icons inside buttons still work. - 03Handle many buttonsName each job with a
data-actionattribute, 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.
Use delegation when many children do the same kind of job. The steps are:
- Put one listener on the parent.
- Find the clicked button with
closest(). - Check that the button is inside your parent.
- Read a label on the button, such as
data-action, to pick the job.
Each step is explained below.
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.targetsays 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
INTERACTIVEHere 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:
- It looks at the element where the click started.
- It finds the nearest element that has a
data-actionattribute. - It runs the job that the attribute names.
ul.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();});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.
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.
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()
INTERACTIVEThe 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.
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.
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);});Choose where the click lands, then click Run to compare matches() and closest().
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.
| Tool | What it gives you | How delegation uses it |
|---|---|---|
event.target | The deepest element where the event started, such as an icon. | Start here. It may not be the button you want. |
event.currentTarget | The 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 Elementasks: "is this value an element?"returnstops the listener early when the answer is no.
Name each job with a data attribute
INTERACTIVEThe 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.
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.
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; }});One listener on this demo panel handles three jobs: show, copy, and count.
Each job in the code follows the same three steps:
- Find the nearest element with that data attribute.
- Check that the element is inside the panel.
- 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.
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.
// 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.
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.
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 THROUGHNow 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.
Choose where the click lands, then step through the listener. The model runs the same checks as the live playgrounds.
script
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);});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.
closest() climbs past the list to the outer row. list.contains(button) is false, so the listener ignores the click.Delegate or not?
SORT ITDelegation 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.
- 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
Sort each case by the kind of listener you would choose.
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, orpointerover. - 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.
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.
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:
- Pick the smallest parent that holds all the repeated buttons and stays on the page.
- Listen on that parent for an event that bubbles.
- Check that
event.targetis anElement. - Use
closest()to find the button that was clicked. - Check
parent.contains(button). - Read its
datasetvalues, and call a small function for the job.
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.
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.
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.
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.
| Question | One listener on the parent | A 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. |
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 EXERCISESRead the small model and predict what it prints.
const target = "span.icon";
const button = target.includes("span") ? "delete" : null;
console.log(button ?? "ignored");button becomes "delete", so button ?? "ignored" prints delete. This is like a click on an icon that closest() sends to the Delete button.
Use the listener counts from the first playground.
const items = 4;
console.log(items * 2);
console.log(1);4 items times 2 buttons is 8 listeners, one for each button. With delegation, there is still just one listener on the parent.
Write the three checks that make a delegated click listener safe.
function handleClick(event) {
// 1. Check that the target is an Element.
// 2. Find [data-action].
// 3. Ignore matches outside list.
}function handleClick(event) {
if (!(event.target instanceof Element)) return;
const button = event.target.closest("[data-action]");
if (!button || !list.contains(button)) return;
route(button.dataset.action);
}The first check makes closest() safe to call. closest() finds the button even when the click lands on an icon inside it. list.contains(button) makes sure the button is inside this list.
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.
list.addEventListener("click", (event) => {
if (event.target.matches("button")) {
event.target.closest("li").remove();
}
});list.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const button = event.target.closest("button");
if (!button || !list.contains(button)) return;
button.closest("li")?.remove();
});The first code expects the target to be the button itself. The fixed code also works when the click lands on an icon. It checks for an Element, and it ignores buttons outside the list, before it removes a row.
Predict the text a delegated listener would pass to a job function.
const action = "toggle";
const id = "todo-3";
console.log(action + ":" + id);It prints toggle:todo-3. In a real listener, these two parts say which job to run and which item it is for.
Check your understanding
7 QUESTIONSQuestion 1 of 7What does event delegation need, for clicks to work?
Choose an answer to see the explanation.
Question 2 of 7Buttons have icons inside them. Why use
closest()instead of onlymatches()?Choose an answer to see the explanation.
Question 3 of 7What does this print?
Read the code, then predictconst 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.
Question 4 of 7Why check
container.contains(match)afterclosest()?Choose an answer to see the explanation.
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.
Question 6 of 7This code counts listeners with delegation. What does it print?
Read the code, then predictlet attached = 1; const itemsBefore = 3; const itemsAfter = itemsBefore + 2; console.log(attached); console.log(itemsAfter);Choose an answer to see the explanation.
Question 7 of 7
focusdoes 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.targetto start,closest()to find the button, andcontains()to check that the button is inside the parent. - A
data-actionattribute 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.