JavaScript Event Delegation: Complete Tutorial

Learn event delegation in JavaScript: attach one listener to a parent instead of many to each child. Works for dynamically added elements and improves performance.

5 min read

Event delegation is a pattern where you attach one event listener to a parent element instead of attaching a separate listener to every child. When an event fires on a child, it bubbles up to the parent, and the parent's handler inspects event.target to determine which child was interacted with.

The problem it solves is straightforward. Imagine a list of 100 items, each needing a click handler:

javascriptjavascript
// Bad: 100 separate listeners
const items = document.querySelectorAll("li");
items.forEach((item) => {
  item.addEventListener("click", () => {
    console.log(item.textContent);
  });
});

Now imagine those items can change, with new ones added and old ones removed. You need to manage listeners for each change. Event delegation fixes both problems with one listener:

javascriptjavascript
// Good: one listener on the parent
const list = document.querySelector("ul");
 
list.addEventListener("click", (event) => {
  if (event.target.tagName === "LI") {
    console.log(event.target.textContent);
  }
});

One listener handles every <li>, including ones added to the page after the listener is set up.

How Event Bubbling Makes Delegation Possible

When you click an element, the event does not stay on that element. It travels up through every ancestor, triggering any matching listeners along the way. This is called event bubbling.

Event bubbling from child to ancestors

The diagram shows the path a click takes after you click a list item inside a <ul> inside a <div>. The event starts at the <li>, then fires on the <ul>, then the <div>, then <body>, and finally document. At each stop, any click listener on that element runs.

Event delegation exploits exactly this behavior. You place the listener on an ancestor that you know will be in the DOM, and the bubbling mechanism delivers the event to it.

The Core Pattern: Check event.target

The key to event delegation is inspecting event.target to decide whether to act. Here are the three most common ways to check it:

Check by tag name, the simplest option, but fragile if HTML structure changes:

javascriptjavascript
parent.addEventListener("click", (event) => {
  if (event.target.tagName === "BUTTON") {
    console.log("A button was clicked.");
  }
});

Checking tagName works, but it breaks if you later wrap the button in a <span> or change the markup. A CSS selector check with matches() is more flexible because it does not care about the element's tag, only whether it matches the selector you give it:

javascriptjavascript
parent.addEventListener("click", (event) => {
  if (event.target.matches(".delete-btn")) {
    console.log("Delete button clicked.");
  }
});

matches() tests whether the element matches a CSS selector. It is clean and works even when the same class appears on different element types.

Find the nearest matching ancestor with closest(), useful when the target might be a child of the element you care about:

javascriptjavascript
parent.addEventListener("click", (event) => {
  const card = event.target.closest(".card");
  if (card) {
    console.log("Card clicked:", card.dataset.id);
  }
});

closest() walks up from event.target and returns the first ancestor that matches the selector, or null if none is found. Use this when your clickable element contains child elements like icons or text nodes, and a click on a child should still count as a click on the parent.

A Practical Example: Todo List with Delete Buttons

Here is a complete example that shows delegation in action. The listener sits on the list itself and finds the delete button through event.target:

javascriptjavascript
const todoList = document.querySelector("#todo-list");
 
todoList.addEventListener("click", (event) => {
  const deleteBtn = event.target.closest(".delete-btn");
  if (!deleteBtn) return;
 
  const todoItem = deleteBtn.closest("li");
  todoItem.remove();
  console.log("Todo removed.");
});

Clicking a delete button removes its parent <li>. The closest(".delete-btn") check means the handler ignores clicks anywhere else in the list. Now add a function that creates new todo items after the page has already loaded:

javascriptjavascript
function addTodo(text) {
  const li = document.createElement("li");
  li.innerHTML = `${text} <button class="delete-btn">Delete</button>`;
  todoList.appendChild(li);
}
 
addTodo("New dynamic item");

The delete button on this new item works immediately, even though it did not exist when the listener was set up. No need to attach a separate listener for it.

When Event Delegation Works (and When It Does Not)

Event delegation depends on event bubbling. It works for events that bubble:

Event TypeBubbles?Delegation Works?
clickYesYes
keydown / keyupYesYes
changeYesYes
submitYesYes
mouseover / mouseoutYesYes
focusNoNo, use focusin instead
blurNoNo, use focusout instead
scrollNo on elements, yes on documentLimited

For focus and blur, use focusin and focusout which do bubble and work with delegation.

Delegating Keyboard Events

Event delegation works with keyboard events too. Here is a table where pressing Enter on any row highlights it:

javascriptjavascript
const table = document.querySelector("table");
 
table.addEventListener("keydown", (event) => {
  const row = event.target.closest("tr");
  if (!row || event.key !== "Enter") return;
 
  row.classList.toggle("highlighted");
  console.log(`Toggled row: ${row.rowIndex}`);
});

This pattern is useful for accessible list navigation, data tables, and any scenario where keyboard users need to interact with repeating elements.

Event Delegation vs Individual Listeners

Event DelegationIndividual Listeners
Number of listenersOne per parentOne per child
Dynamic elementsHandled automaticallyNeed manual setup
Memory usageLower for many elementsHigher for many elements
ComplexityRequires target checkingSimple and direct
Best forLists, tables, grids, dynamic contentFixed, small sets of elements

For a page with five buttons, individual listeners are fine. For a product grid with 200 items, a search result list, or any interface where elements are added and removed, event delegation is the right choice.

Event delegation builds on two fundamental concepts: attaching listeners and understanding how events bubble. If you are unfamiliar with either, read /javascript/how-to-add-event-listeners-in-js-complete-guide and /javascript/handling-click-events-in-javascript-full-guide first.

Common Mistakes

Forgetting that event.target may be a child element. If your button contains an icon or text node, clicking the icon makes event.target the icon, not the button. Always use closest() when your clickable element has children:

javascriptjavascript
// Wrong: breaks when the button has an icon inside
if (event.target.classList.contains("btn")) { ... }
 
// Right: works regardless of which child was clicked
const btn = event.target.closest(".btn");
if (btn) { ... }

Not checking event.target at all. If your delegated listener runs for every click on the parent without checking event.target, it fires on clicks between elements, on padding, or on unrelated children. Always guard with an if check.

Using delegation for non-bubbling events. If you try to delegate focus or blur, it will not work. Switch to focusin and focusout or attach listeners directly to the elements that need them.

Rune AI

Rune AI

Key Insights

  • Event delegation attaches one listener to a parent instead of many to each child.
  • Use event.target to identify which child element triggered the event.
  • The matches() method checks if the target matches a CSS selector.
  • Delegation automatically works for elements added to the page later.
  • Only events that bubble (click, keydown, change) work with delegation.
  • Use closest() to find the nearest ancestor that matches a selector.
RunePowered by Rune AI

Frequently Asked Questions

Does event delegation work with all event types?

No. Event delegation relies on event bubbling, so it only works with events that bubble. Most user interaction events like click, keydown, and change do bubble. Events like focus, blur, and scroll do not bubble, but you can use focusin and focusout which do bubble as alternatives.

Is event delegation faster than individual listeners?

For a small number of elements, the difference is negligible. For hundreds or thousands of elements (like a long list or table), event delegation is significantly more memory-efficient because you create one listener instead of hundreds.

How do I stop delegated events from firing on the wrong child?

Check event.target inside your handler. Use matches() to filter by selector, closest() to find the nearest matching ancestor, or tagName for simple element type checks.

Conclusion

Event delegation is a pattern where you attach a single event listener to a parent element and use event.target to determine which child triggered the event. It reduces the number of listeners, automatically handles dynamically added elements, and is the foundation for scalable event handling in JavaScript.