Event Delegation Get Certified
Instead of attaching a separate listener to every child element, you can attach one listener to a common parent and let events bubble up to it. This technique is called event delegation.
Delegation is more efficient, cleaner, and automatically works for elements that are added to the page later.
Why Use Event Delegation?
Attaching individual listeners to dozens or hundreds of similar elements wastes memory and makes the code harder to maintain. A single delegated listener on the parent solves both problems.
Basic Event Delegation
What this code does: One listener on the parent handles clicks for
every child. The actual clicked element is available as
event.target.
function makeElement(tag) {
const listeners = {};
return {
tag: tag,
textContent: "",
parentElement: null,
addEventListener(type, handler) {
if (!listeners[type]) listeners[type] = [];
listeners[type].push(handler);
},
dispatch(type, event) {
if (listeners[type]) {
listeners[type].forEach(handler => handler(event));
}
if (!event.propagationStopped && this.parentElement) {
this.parentElement.dispatch(type, event);
}
}
};
}
const menuList = makeElement("ul");
const itemA = makeElement("li");
itemA.textContent = "Home";
itemA.parentElement = menuList;
const itemB = makeElement("li");
itemB.textContent = "About";
itemB.parentElement = menuList;
menuList.addEventListener("click", function (event) {
console.log("Clicked:", event.target.textContent);
});
itemA.dispatch("click", { target: itemA, propagationStopped: false });
itemB.dispatch("click", { target: itemB, propagationStopped: false });
function makeElement(tag) { creates a simplified DOM-like element for the demos.const listeners = {}; holds event-type → handler arrays for this element only.return { begins the public interface of the element.tag: tag, stores the element’s tag for identification.textContent: "", will hold the visible label of list items.parentElement: null, used for bubbling upward.addEventListener(type, handler) stores the handler under the given event type so it can be called later.dispatch(type, event) first runs any local handlers, then — unless stopped — forwards the same event to parentElement, simulating the real DOM bubble phase.}; and } finish the factory.const menuList = makeElement("ul"); this is the single element that will hold the delegated listener.itemA is labelled “Home” and linked upward to the list.itemB is labelled “About” and also linked to the same list.menuList.addEventListener("click", ...) logs whichever child was the original target. No listeners are attached to the items themselves.itemA.dispatch(...) fires a click on Home; the event bubbles to the list and prints “Clicked: Home”.itemB.dispatch(...) does the same for About. The same parent listener handles both.Matching Only Specific Children
A delegated listener often needs to check whether
event.target is actually the kind of element it
cares about, using something like closest(), since
the click might have landed on a nested element inside it.
What this code does:
Demonstrates matching a specific child even when the real click lands on a nested element
(the icon). closest(".buy-button") walks up the tree to find the intended button.
function makeElement(tag, className) {
const listeners = {};
return {
tag: tag,
className: className || "",
parentElement: null,
addEventListener(type, handler) {
if (!listeners[type]) listeners[type] = [];
listeners[type].push(handler);
},
dispatch(type, event) {
if (listeners[type]) {
listeners[type].forEach(handler => handler(event));
}
if (!event.propagationStopped && this.parentElement) {
this.parentElement.dispatch(type, event);
}
},
closest(selector) {
let current = this;
while (current) {
if ("." + current.className === selector) return current;
current = current.parentElement;
}
return null;
}
};
}
const productList = makeElement("div");
const buyButton = makeElement("button", "buy-button");
buyButton.parentElement = productList;
const buttonIcon = makeElement("span", "icon");
buttonIcon.parentElement = buyButton;
productList.addEventListener("click", function (event) {
const clickedButton = event.target.closest(".buy-button");
if (clickedButton) {
console.log("Buy button activated!");
} else {
console.log("Click ignored — not a buy button.");
}
});
buttonIcon.dispatch("click", { target: buttonIcon, propagationStopped: false });
function makeElement(tag, className) {const listeners = {};return {tag: tag,className: className || "", used by the simulated closest method.parentElement: null,closest(selector) walks upward via parentElement until it finds a matching class or reaches the top.const productList = makeElement("div");buyButton has class buy-button and is a child of the list.buttonIcon sits inside the buy button — the actual click target will be this nested span.event.target.closest(".buy-button") finds the button even when the click landed on the icon inside it. If a match is found the buy action runs; otherwise the click is ignored.buttonIcon.dispatch(...) the event bubbles, closest locates the buy button, and “Buy button activated!” is printed.
Even though the click technically landed on the small icon inside
the button, closest(".buy-button") correctly walks
up to find the actual button that should handle it.
Stopping Propagation
Calling event.stopPropagation() prevents the event
from continuing to bubble up past the current element, which can
be useful when a nested element needs to handle a click without
triggering a listener on its container.
What this code does:
Shows how stopPropagation() prevents the event from continuing to bubble.
Clicking the close button logs its own message and never triggers the modal’s background listener.
function makeElement(tag) {
const listeners = {};
return {
tag: tag,
parentElement: null,
addEventListener(type, handler) {
if (!listeners[type]) listeners[type] = [];
listeners[type].push(handler);
},
dispatch(type, event) {
if (listeners[type]) {
listeners[type].forEach(handler => handler(event));
}
if (!event.propagationStopped && this.parentElement) {
this.parentElement.dispatch(type, event);
}
}
};
}
function makeEvent(target) {
return {
target: target,
propagationStopped: false,
stopPropagation() { this.propagationStopped = true; }
};
}
const modal = makeElement("div");
const closeButton = makeElement("button");
closeButton.parentElement = modal;
modal.addEventListener("click", () => console.log("Modal background clicked — closing modal."));
closeButton.addEventListener("click", function (event) {
console.log("Close button clicked.");
event.stopPropagation();
});
closeButton.dispatch("click", makeEvent(closeButton));
function makeElement(tag) {const listeners = {};return {tag: tag,parentElement: null,propagationStopped is true.makeEvent(target) builds a minimal event object that carries the original target and a stopPropagation method which sets propagationStopped = true.const modal = makeElement("div");closeButton.parentElement = modal;event.stopPropagation() so the event never bubbles to the modal.closeButton.dispatch(...) prints only “Close button clicked.” The modal’s listener never runs because propagation was stopped.
Because stopPropagation() was called, the modal’s
own click listener never runs — the event stopped bubbling right
after the close button handled it.
Handling Elements Added Later
One of the biggest advantages of delegation is that it automatically works for elements added to the page after the listener was set up — no extra code required.
What this code does: Proves that a delegated listener still works for elements created after the listener was attached. The new comment is handled correctly without any extra setup.
function makeElement(tag) {
const listeners = {};
return {
tag: tag,
textContent: "",
parentElement: null,
addEventListener(type, handler) {
if (!listeners[type]) listeners[type] = [];
listeners[type].push(handler);
},
dispatch(type, event) {
if (listeners[type]) {
listeners[type].forEach(handler => handler(event));
}
if (!event.propagationStopped && this.parentElement) {
this.parentElement.dispatch(type, event);
}
}
};
}
const commentSection = makeElement("div");
commentSection.addEventListener("click", function (event) {
console.log("Clicked comment:", event.target.textContent);
});
// This comment element is created *after* the listener was set up.
const newComment = makeElement("p");
newComment.textContent = "Great article!";
newComment.parentElement = commentSection;
newComment.dispatch("click", { target: newComment, propagationStopped: false });
function makeElement(tag) {const listeners = {};return {tag: tag,textContent: "",parentElement: null,const commentSection = makeElement("div");commentSection.addEventListener("click", ...) is registered before any comment elements exist.// This comment element is created *after* the listener was set up.newComment is created and parented to the section after the listener already exists.newComment.dispatch(...) the event bubbles to the section and the existing listener prints “Clicked comment: Great article!” — no re-attachment was needed.
The listener still catches the click, even though
newComment didn’t exist yet when
addEventListener() was called on the container.
Practical Example
Let’s build a delegated “delete” handler for a to-do list, where each item has its own small delete button, but only one listener is attached to the whole list.
What this code does:
A practical “delete” handler for a to-do list. One listener on the list uses
closest() twice—once to detect the delete button, once to locate its parent item.
function makeElement(tag, className) {
const listeners = {};
return {
tag: tag,
className: className || "",
textContent: "",
parentElement: null,
addEventListener(type, handler) {
if (!listeners[type]) listeners[type] = [];
listeners[type].push(handler);
},
dispatch(type, event) {
if (listeners[type]) {
listeners[type].forEach(handler => handler(event));
}
if (!event.propagationStopped && this.parentElement) {
this.parentElement.dispatch(type, event);
}
},
closest(selector) {
let current = this;
while (current) {
if ("." + current.className === selector) return current;
current = current.parentElement;
}
return null;
}
};
}
const todoList = makeElement("ul");
const taskItem = makeElement("li", "todo-item");
taskItem.textContent = "Water the garden";
taskItem.parentElement = todoList;
const deleteButton = makeElement("button", "delete-button");
deleteButton.parentElement = taskItem;
todoList.addEventListener("click", function (event) {
const clickedDelete = event.target.closest(".delete-button");
if (clickedDelete) {
const item = clickedDelete.closest(".todo-item");
console.log("Deleting task:", item.textContent);
}
});
deleteButton.dispatch("click", { target: deleteButton, propagationStopped: false });
function makeElement(tag, className) {const listeners = {};return {tag: tag,className: className || "",textContent: "",parentElement: null,const todoList = makeElement("ul");taskItem has class todo-item, holds the task text, and is a child of the list.deleteButton has class delete-button and is nested inside the task item.event.target.closest(".delete-button") detects whether a delete button was clicked; a second closest(".todo-item") finds the surrounding task so its text can be logged.deleteButton.dispatch(...) bubbles to the list, both closest calls succeed, and “Deleting task: Water the garden” is printed.When Delegation Isn’t the Right Fit
Not every event bubbles — for example, focus and
blur don’t bubble by default, so delegation doesn’t
work for them the same way (though related bubbling versions,
like focusin and focusout, do exist).
Delegation is also less useful for a single, unique element that
will never be duplicated or recreated.
Common Mistakes With Event Delegation
- Assuming
event.targetis always the element you attached logic for, instead of checking withclosest()or a similar match. - Forgetting that some events, like
focusandblur, don’t bubble in the same way asclick. - Overusing
stopPropagation(), which can accidentally prevent other unrelated listeners further up the tree from ever running. - Attaching a delegated listener to an element far higher up the tree than necessary, making the matching logic more complex than it needs to be.
- Re-attaching individual listeners to newly created elements out of habit, when a single delegated listener already handles them.
Best Practices for Event Delegation
- Attach the delegated listener to the closest reasonable shared ancestor, not necessarily the very top of the page.
- Use
closest()to reliably match the intended element, even if the actual click landed on something nested inside it. - Reach for delegation especially for lists, tables, and other groups of similar, repeatable elements.
- Use
stopPropagation()sparingly and intentionally, only when you specifically need to prevent further bubbling. - Remember that delegation automatically covers elements added later — no need to reattach listeners after updating a list.
Quick Summary
- Events bubble upward from the target element through its ancestors, unless stopped.
- Event delegation attaches one listener to a shared parent instead of many listeners on individual children.
event.targetidentifies the actual element that triggered the event;closest()helps match the right ancestor.event.stopPropagation()prevents an event from bubbling further.- Delegation automatically works for elements added to the page after the listener was attached.
- Not all events bubble, so delegation doesn’t apply universally to every event type.
Try It Yourself
Modify the code below. Try adding a second item to the list, or changing which element is clicked, to see the delegated listener respond to each one.
What this code does: A gallery container listens for clicks on any of its photo elements. Change which photo is dispatched (or add another) and watch the same listener respond to each one.
function makeElement(tag) {
const listeners = {};
return {
tag: tag,
textContent: "",
parentElement: null,
addEventListener(type, handler) {
if (!listeners[type]) listeners[type] = [];
listeners[type].push(handler);
},
dispatch(type, event) {
if (listeners[type]) {
listeners[type].forEach(handler => handler(event));
}
if (!event.propagationStopped && this.parentElement) {
this.parentElement.dispatch(type, event);
}
}
};
}
const gallery = makeElement("div");
const photoOne = makeElement("img");
photoOne.textContent = "sunset.jpg";
photoOne.parentElement = gallery;
const photoTwo = makeElement("img");
photoTwo.textContent = "mountains.jpg";
photoTwo.parentElement = gallery;
gallery.addEventListener("click", function (event) {
console.log("Opening photo:", event.target.textContent);
});
photoTwo.dispatch("click", { target: photoTwo, propagationStopped: false });
function makeElement(tag) {const listeners = {};return {tag: tag,textContent: "", used here to hold the photo filename.parentElement: null,const gallery = makeElement("div");photoOne is labelled “sunset.jpg” and parented to the gallery.photoTwo is labelled “mountains.jpg” and also parented to the gallery.gallery.addEventListener("click", ...) logs whichever photo was the original target. No per-photo listeners are needed.photoTwo.dispatch(...) bubbles to the gallery and prints “Opening photo: mountains.jpg”. Try dispatching photoOne or adding a third photo.Tip: A good rule of thumb: if you’re about to write a loop that attaches the exact same listener to many similar elements, pause — a single delegated listener on their shared parent is very likely the cleaner, more maintainable option.
Understanding event delegation is essential before moving on to forms, custom events, and more advanced browser-based JavaScript concepts.
🧪 Test Your JavaScript Code
Edit the code on the left and click “Run Code” to see the console output on the right.
Click “Run Code” to see the output here.
Code Explanation
The complete example stays visible. Use Previous and Next to move through its logical blocks.
Select Next to begin exploring the code.