JavaScript Event Delegation: One Listener for Many Elements
Learn event delegation: use bubbling to handle clicks on many or dynamically added elements with one listener, using target, closest and data attributes.
What you will learn
Imagine a to-do app with 100 tasks, and every task has a Delete button. You could add 100 listeners, one for each button. But what happens when the user adds a new task? The new button has no listener, so it does nothing. And 100 listeners use memory for no reason.
There is a much better way: add one listener to the parent, and let the clicks bubble up to it. This technique is called event delegation. It is one of the most useful patterns in everyday JavaScript. In this lesson you will learn how it works, how to write it safely with event.target and closest(), and how to use it for to-do lists, tabs, carts and more.
You should already know bubbling from the last lesson. If not, read JavaScript Event Propagation first.
The problem with one listener per element
Here is a small task list:
<ul id="todo-list">
<li data-id="1">Learn HTML <button class="delete">Delete</button></li>
<li data-id="2">Learn CSS <button class="delete">Delete</button></li>
<li data-id="3">Learn JavaScript <button class="delete">Delete</button></li>
</ul>
<input id="new-task" type="text" placeholder="New task">
<button id="add-task">Add</button>
The beginner approach is to loop over the buttons and add a listener to each:
const buttons = document.querySelectorAll(".delete");
buttons.forEach((button) => {
button.addEventListener("click", function () {
button.closest("li").remove();
});
});
It works for the three tasks that exist now. But look at what happens when we add a new task:
const list = document.getElementById("todo-list");
// Listeners are attached only to buttons that exist at this moment
document.querySelectorAll(".delete").forEach((button) => {
button.addEventListener("click", () => button.closest("li").remove());
});
// Later, the user adds a task
const li = document.createElement("li");
li.innerHTML = 'Learn React <button class="delete">Delete</button>';
list.append(li);
// Clicking the new Delete button does nothing: it has no listener
You have two problems:
- New elements are ignored.
querySelectorAllreturned a snapshot, so later buttons never got a listener. - Many listeners. A page with a table of 1,000 rows would create 1,000 functions.
The idea of delegation
Remember that a click bubbles from the clicked element up through all of its ancestors. So the <ul> hears about every click on any <li> or <button> inside it. Instead of hiring 100 guards, one for each door, you hire one guard at the front gate who checks who is coming through.
The steps:
- Add one listener to a parent that never changes (the
<ul>). - When it runs, look at
event.targetto find out what was really clicked. - If it is something you care about, handle it. If not, ignore it.
Your first delegated listener
const list = document.getElementById("todo-list");
list.addEventListener("click", function (event) {
console.log("You clicked:", event.target.tagName);
});
Click a button, then click the text of a task, then click the empty space in the list. The Console shows BUTTON, LI and so on. The <ul> listener heard every click, and event.target told us who it was.
Now let us delete only when a Delete button was clicked:
const list = document.getElementById("todo-list");
list.addEventListener("click", function (event) {
if (event.target.classList.contains("delete")) {
event.target.closest("li").remove();
}
});
This single listener now works for the three tasks and for any task added later, because the listener belongs to the <ul>, not to the buttons.
Dynamic elements just work
Let us prove it. This is the full app. There is no code that adds listeners to new tasks:
const list = document.getElementById("todo-list");
const input = document.getElementById("new-task");
const addBtn = document.getElementById("add-task");
// One delegated listener
list.addEventListener("click", function (event) {
if (event.target.classList.contains("delete")) {
event.target.closest("li").remove();
}
});
// Adding a task: just create and append
addBtn.addEventListener("click", function () {
const text = input.value.trim();
if (text === "") return;
const li = document.createElement("li");
li.textContent = text + " ";
const del = document.createElement("button");
del.className = "delete";
del.textContent = "Delete";
li.append(del);
list.append(li);
input.value = "";
});
Type a task, press Add, then press its Delete button. It works the first time, with no extra code.
The “icon inside a button” problem
Real buttons often contain other elements, such as an icon:
<button class="delete"><span>🗑</span> Delete</button>
If the user clicks the little icon, then event.target is the <span>, not the <button>. So this check silently fails:
const list = document.getElementById("todo-list");
list.addEventListener("click", function (event) {
if (event.target.classList.contains("delete")) {
// Does not run when the icon <span> is clicked!
event.target.closest("li").remove();
}
});
The fix is closest(). It starts at event.target and walks up until it finds a match for your selector. If the user clicked the span, closest(".delete") goes up one step and finds the button. If the user clicked the button directly, it finds the button itself.
const list = document.getElementById("todo-list");
list.addEventListener("click", function (event) {
const button = event.target.closest(".delete");
if (button) {
button.closest("li").remove();
}
});
You met closest() in the DOM traversal lesson. Together with delegation, it forms the standard pattern:
parent.addEventListener("click", function (event) {
const thing = event.target.closest(".selector-you-want");
if (!thing) return; // click was somewhere else
if (!parent.contains(thing)) return; // thing is outside this parent
// ...do the work with `thing`
});
Why the second check? closest() goes up through all ancestors, even those outside your parent. If your list is itself inside another element that also matches .selector-you-want, closest might find that outer one. parent.contains(thing) keeps you safe. For a simple page you can skip it, but adding it costs one line and avoids rare bugs.
Using matches()
If you only care about the exact element and not its children, event.target.matches("selector") returns true or false:
const list = document.getElementById("todo-list");
list.addEventListener("click", function (event) {
if (event.target.matches("li")) {
console.log("You clicked directly on a task row");
}
});
Use closest() when the target might be a child of what you want. Use matches() when you want to be strict.
The data-action pattern
Real apps have several buttons per row: Done, Edit, Delete. Instead of checking class names, put the intention in a data- attribute, then use one switch:
<li data-id="2">
<span class="text">Learn CSS</span>
<button data-action="done">Done</button>
<button data-action="delete">Delete</button>
</li>
const list = document.getElementById("todo-list");
list.addEventListener("click", function (event) {
const button = event.target.closest("button[data-action]");
if (!button || !list.contains(button)) return;
const item = button.closest("li");
switch (button.dataset.action) {
case "done":
item.classList.toggle("done");
break;
case "delete":
item.remove();
break;
}
});
button[data-action]selects only buttons that have the attribute.button.dataset.actionreadsdata-action. (datasetturnsdata-user-idintodataset.userId.)- Adding a new action later, like
edit, means adding onecase. No new listeners.
For the done action to look right, add a little CSS:
#todo-list li.done .text {
text-decoration: line-through;
color: #888;
}
Real use case: tabs
A tab bar is a perfect fit. One listener on the container handles every tab:
<div class="tabs" id="tabs">
<button data-tab="html" class="active">HTML</button>
<button data-tab="css">CSS</button>
<button data-tab="js">JavaScript</button>
</div>
<div class="panel" id="panel">HTML gives a page its structure.</div>
.tabs {
display: flex;
gap: 6px;
}
.tabs button.active {
background: #2563eb;
color: white;
border-color: #2563eb;
}
const tabs = document.getElementById("tabs");
const panel = document.getElementById("panel");
const content = {
html: "HTML gives a page its structure.",
css: "CSS makes the page look good.",
js: "JavaScript makes the page interactive."
};
tabs.addEventListener("click", function (event) {
const tab = event.target.closest("button[data-tab]");
if (!tab) return;
for (const button of tabs.children) {
button.classList.remove("active");
}
tab.classList.add("active");
panel.textContent = content[tab.dataset.tab];
});
Add a fourth tab in the HTML and one more line in content, and it works. The JavaScript logic does not change.
Real use case: cart quantities
An online cart has + and - buttons on every row, and rows are added and removed all the time.
<div id="cart">
<div class="cart-row" data-name="Keyboard">
Keyboard <button data-action="dec">-</button>
<span class="qty">1</span>
<button data-action="inc">+</button>
</div>
<div class="cart-row" data-name="Mouse">
Mouse <button data-action="dec">-</button>
<span class="qty">1</span>
<button data-action="inc">+</button>
</div>
</div>
const cart = document.getElementById("cart");
cart.addEventListener("click", function (event) {
const button = event.target.closest("button[data-action]");
if (!button || !cart.contains(button)) return;
const row = button.closest(".cart-row");
const qtyEl = row.querySelector(".qty");
let qty = Number(qtyEl.textContent);
if (button.dataset.action === "inc") qty++;
if (button.dataset.action === "dec") qty = Math.max(1, qty - 1);
qtyEl.textContent = qty;
console.log(row.dataset.name, "quantity is now", qty);
});
The same listener serves every row. A row added later by fetch or innerHTML is covered automatically.
Delegating other events
Delegation works for any event that bubbles, not only click.
Typing in many inputs with input:
const form = document.createElement("form");
form.innerHTML = '<input name="first"><input name="last"><input name="city">';
document.body.append(form);
form.addEventListener("input", function (event) {
console.log(event.target.name, "changed to", event.target.value);
});
Hover with mouseover: use mouseover and mouseout (these bubble), not mouseenter and mouseleave (these do not):
const list = document.getElementById("todo-list");
list.addEventListener("mouseover", function (event) {
const item = event.target.closest("li");
if (item && list.contains(item)) {
item.style.background = "#f3f4f6";
}
});
list.addEventListener("mouseout", function (event) {
const item = event.target.closest("li");
if (item && list.contains(item)) {
item.style.background = "";
}
});
Focus: focus and blur do not bubble, but focusin and focusout do, so use those on a parent:
const form = document.createElement("form");
form.innerHTML = '<input name="email"><input name="phone">';
document.body.append(form);
form.addEventListener("focusin", (e) => e.target.style.outline = "2px solid royalblue");
form.addEventListener("focusout", (e) => e.target.style.outline = "");
Choose the right parent
Where should the delegated listener go?
- Close to the elements. Use the nearest stable parent (
#todo-list), notdocument. The event travels a shorter path, and your code does not run for every click on the page. - A parent that is never replaced. If you wipe the parent with
parent.innerHTML = ...and rebuild it, the listener on the parent is not affected, but the parent must still be the same element. If you replace the parent itself, the listener goes with it. documentis allowed for global features, like “click outside to close”, keyboard shortcuts, or analytics.
Advantages and limits
Advantages
- Works for elements added later. No need to attach listeners again.
- Less memory. One function instead of hundreds.
- Less code to clean up. Removing an item does not leave an orphan listener.
- One place for the logic. Easier to read and test.
Limits
- Only events that bubble. For
focus,blur,load,mouseenterandmouseleave, use the bubbling alternatives (focusin,focusout,mouseover,mouseout) or the capturing phase. stopPropagationbreaks it. If a child stops the event, the parent never hears about it.- A tiny extra check for each event. Every click inside the parent runs your listener, and it exits quickly if the target is not interesting. This cost is normally unnoticeable.
event.targetcan be a child you did not expect (like the icon). Useclosest().
Tip: Delegation is not always needed. For a single button that always exists, a normal listener is simpler and perfectly fine. Use delegation when you have many similar elements or elements that are added and removed while the page is open.
Common mistakes
- Checking
event.targetdirectly when the button has children. Clicking an icon or<strong>inside the button makestargetthe child. Useevent.target.closest("button"). - Forgetting the guard. Without
if (!button) return;, clicks on empty space run your code withnulland crash withCannot read properties of null. - Attaching listeners to
querySelectorAllresults and expecting new elements to work. The list is a snapshot. Delegate instead. - Using
mouseenter,mouseleave,focusorblurfor delegation. They do not bubble. Usemouseover,mouseout,focusinandfocusout. - Putting the listener on a parent that gets replaced. Choose a stable parent.
- Using
documentfor everything. It works, but it runs on every click in the page. Pick the nearest stable parent. - Calling
stopPropagation()in a child. It cuts the path to the delegated parent, so the click is never handled. - Using
event.currentTargetwhen you meantevent.target. In delegation,currentTargetis always the parent.targetis what was clicked. - Not checking
parent.contains(found)when the parent is nested inside other matching elements. - Trusting
innerHTMLwith user text. In the to-do example, put user text in withtextContent, not into an HTML string. Otherwise someone can type HTML or script into your page.
Practice
- Click Practice in Editor. Click the Done button on a task and confirm it gets a line through it. Click it again to undo.
- Click the little trash icon (not the word “Delete”). Does the task still get deleted? Why?
- Add three new tasks with the input. Without writing any new code, check that Done and Delete work on them.
- Change the code so that clicking the text of a task also toggles
done. Hint: checkevent.target.closest(".text"). - Add an Edit button to every task with
data-action="edit". Useprompt()to ask for new text, and update the.textelement. - Add a fourth tab,
Git, with its own content. How many lines of JavaScript did you change? - Make the cart quantity unable to go above
10. - Show a total number of items in the cart that updates on every click. Hint: add up all
.qtyvalues. - Use
mouseoverandmouseoutdelegation to highlight the task row under the mouse. - Challenge: add a “Clear completed” button that removes every
li.doneusingquerySelectorAll. - Challenge: break the delegation on purpose by adding
event.stopPropagation()to a click listener on one Delete button. What happens? How would you fix it?
Recap
- Event delegation means putting one listener on a parent and letting child events bubble up to it.
- Use
event.targetto see what was clicked. Useevent.target.closest("selector")so that clicks on inner elements (icons, spans) still work. - Always add a guard:
if (!match) return;. Addparent.contains(match)for safety in nested layouts. - Delegation works automatically for elements that are added later, and saves memory for long lists.
- The
data-actionpattern (button.dataset.action) keeps several actions in one cleanswitch. - It works for events that bubble. For others, use
focusin,focusout,mouseover,mouseout, or capturing. - Do not call
stopPropagation()on children whose events a parent needs to hear. - Use delegation for lists, tables, menus, tabs and carts. For one simple fixed button, a normal listener is fine.
- Next you will learn
preventDefault(): how to stop the browser’s built-in behaviour for links and forms.