Quiz

Explain event delegation in JavaScript

Topics
Web APIsJavaScript

TL;DR

Event delegation is a technique in JavaScript where a single event listener is attached to a parent element instead of attaching event listeners to multiple child elements. When an event occurs on a child element, the event bubbles up the DOM tree, and the parent element's event listener handles the event based on the target element.

Event delegation provides the following benefits:

  • Fewer listeners: A single listener can reduce listener bookkeeping and per-item closures for very large collections. Whether that produces a measurable performance improvement depends on the page and should be profiled.
  • Simplified event handling: With event delegation, you only need to write the event handling logic once in the parent element's event listener. This makes the code more maintainable and easier to update.
  • Dynamic element support: Event delegation automatically handles events for dynamically added or removed elements within the parent element. There's no need to manually attach or remove event listeners when the DOM structure changes.

However, do note that:

  • It is important to identify the target element that triggered the event.
  • Not every event bubbles. For example, focus/blur and mouseenter/mouseleave need capture-phase handling or bubbling alternatives such as focusin/focusout and mouseover/mouseout. Element scroll and resize generally need direct listeners.

One ancestor listener for many descendants

Delegation relies on bubbling: a stable ancestor receives descendant events and decides which matching child originated the interaction.

Event delegation through a common ancestor

Because the listener belongs to the ancestor, newly added matching descendants work without separate listener registration.

Event delegation

Event delegation is a design pattern in JavaScript used to efficiently manage and handle events on multiple child elements by attaching a single event listener to a common ancestor element. This pattern is particularly valuable in scenarios where you have a large number of similar elements, such as list items, and want to optimize event handling.

How event delegation works

  1. Attach a listener to a common ancestor: Instead of attaching individual event listeners to each child element, you attach a single event listener to a common ancestor element higher in the DOM hierarchy.
  2. Event bubbling: When an event occurs on a child element, it bubbles up through the DOM tree to the common ancestor element. During this propagation, the event listener on the common ancestor can intercept and handle the event.
  3. Determine the target: Within the event listener, you can inspect the event object to identify the actual target of the event (the child element that triggered the event). Use event.target to find which specific child element was interacted with. (event.currentTarget refers to the ancestor the listener is attached to, not the child that triggered the event.)
  4. Perform action based on target: Based on the target element, you can perform the desired action or execute code specific to that element. This allows you to handle events for multiple child elements with a single event listener.

Benefits of event delegation

  1. Fewer listeners: Event delegation reduces listener bookkeeping and can reduce memory use when dealing with a very large number of elements.
  2. Dynamic elements: It works with dynamically added or removed child elements, as the common ancestor continues to listen for events on them.

Example

Here's a simple example:

// HTML:
// <ul id="item-list">
// <li>Item 1</li>
// <li>Item 2</li>
// <li>Item 3</li>
// </ul>
const itemList = document.getElementById('item-list');
itemList.addEventListener('click', (event) => {
if (event.target.tagName === 'LI') {
console.log(`Clicked on ${event.target.textContent}`);
}
});

In this example, a single click event listener is attached to the <ul> element. When a click event occurs on an <li> element, the event bubbles up to the <ul> element, where the event listener checks the target's tag name to identify whether a list item was clicked. It's crucial to check the identity of the event.target as there can be other kinds of elements in the DOM tree.

Use cases

Event delegation is commonly used in scenarios like:

Handling dynamic content in single-page applications

// HTML:
// <div id="button-container">
// <button>Button 1</button>
// <button>Button 2</button>
// </div>
// <button id="add-button">Add Button</button>
const buttonContainer = document.getElementById('button-container');
const addButton = document.getElementById('add-button');
buttonContainer.addEventListener('click', (event) => {
if (event.target.tagName === 'BUTTON') {
console.log(`Clicked on ${event.target.textContent}`);
}
});
addButton.addEventListener('click', () => {
const newButton = document.createElement('button');
newButton.textContent = `Button ${buttonContainer.children.length + 1}`;
buttonContainer.appendChild(newButton);
});

In this example, a click event listener is attached to the <div> container. When a new button is added dynamically and clicked, the event listener on the container handles the click event.

Simplifying code by avoiding the need to attach and remove event listeners for elements that change

// HTML:
// <form id="user-form">
// <input type="text" name="username" placeholder="Username">
// <input type="email" name="email" placeholder="Email">
// <input type="password" name="password" placeholder="Password">
// </form>
const userForm = document.getElementById('user-form');
userForm.addEventListener('input', (event) => {
const { name, value } = event.target;
console.log(`Changed ${name}: ${value}`);
});

In this example, a single input event listener is attached to the form element. It can respond to input changes for all child input elements, simplifying the code by eliminating the need for individual listeners on each <input> element.

More real-world delegation patterns

Beyond the simple list-item example, three patterns show up constantly in production code.

Form-wide delegated change handler

A single change or input listener on the form catches every input update, which is useful for autosave, dirty-tracking, and validation:

// HTML: <form id="profile-form"> with many inputs/selects/textareas inside
const form = document.getElementById('profile-form');
form.addEventListener('input', (event) => {
// Works for any <input>, <select>, or <textarea> the form contains,
// even ones added later by the user.
const field = event.target;
console.log(`${field.name} changed to ${field.value}`);
scheduleAutosave(field.name, field.value);
});

No matter how many fields the form has, or whether new fields are appended dynamically, only one listener is needed.

Data-table row actions

Modern data-table UIs commonly use a single click handler on the table that reads data-action from the clicked element to know what to do. This is delegation plus the data-attribute pattern:

// HTML rows look like:
// <tr>
// <td>...</td>
// <td>
// <button data-action="edit" data-id="42">Edit</button>
// <button data-action="delete" data-id="42">Delete</button>
// </td>
// </tr>
document.querySelector('table').addEventListener('click', (event) => {
const button = event.target.closest('[data-action]');
if (!button) {
return;
}
const { action, id } = button.dataset;
if (action === 'edit') {
openEditor(id);
}
if (action === 'delete') {
confirmDelete(id);
}
});

event.target.closest() is the workhorse here. It walks up from the click target to the nearest matching ancestor, which makes the handler robust against inner spans, icons, and styling wrappers.

Click-to-edit cells

A spreadsheet-style cell editor uses delegation to promote whichever cell was clicked into an editable input, without attaching one listener per cell:

document.querySelector('table').addEventListener('click', (event) => {
const cell = event.target.closest('td.editable');
if (!cell || cell.querySelector('input')) {
return; // already editing
}
const original = cell.textContent;
const input = document.createElement('input');
input.value = original;
cell.textContent = '';
cell.appendChild(input);
input.focus();
input.addEventListener('blur', () => {
cell.textContent = input.value;
if (input.value !== original) saveCell(cell.dataset.id, input.value);
});
});

Delegating non-bubbling events

The TL;DR notes that focus, blur, scroll, mouseenter, mouseleave, and resize do not bubble, so the obvious delegation pattern (one listener on the parent) does not work for them. Two solid workarounds:

Use the capture phase

Pass a third argument of true (or { capture: true }) to listen during the capture phase. The event is visible to ancestors on the way down to the target, even if it does not bubble back up:

document.body.innerHTML = `
<div id="form">
<input id="a" placeholder="A">
<input id="b" placeholder="B">
</div>
`;
document.getElementById('form').addEventListener(
'focus',
(event) => {
console.log('focused:', event.target.id);
},
true, // capture: catch focus before it stops at the input
);
// In a real page, focus events fire automatically when the user clicks or
// tabs into an input. Here we dispatch them by hand so the demo prints the
// same sequence in this playground.
['a', 'b'].forEach((id) => {
document.getElementById(id).dispatchEvent(new FocusEvent('focus'));
});
// Logs: focused: a, then focused: b

Use the bubbling siblings: focusin and focusout

focus does not bubble, but focusin does. The same is true for blur and focusout. The pointer events have similar pairs: mouseover and mouseout bubble, while mouseenter and mouseleave do not (note that mouseover and mouseout also fire when the pointer crosses descendant boundaries, so the semantics are slightly different). The bubbling variants exist specifically to support delegation:

document.body.innerHTML = `
<form id="form">
<input id="a" placeholder="A">
<input id="b" placeholder="B">
</form>
`;
const form = document.getElementById('form');
form.addEventListener('focusin', (event) =>
console.log('focusin:', event.target.id),
);
form.addEventListener('focusout', (event) =>
console.log('focusout:', event.target.id),
);
// In a real page, focusin and focusout fire automatically when the user
// moves between inputs. Here we dispatch them by hand to simulate the user
// moving focus from input A to input B inside this playground.
const a = document.getElementById('a');
const b = document.getElementById('b');
[
[a, 'focusin'], // focusin: a
[a, 'focusout'], // focusout: a
[b, 'focusin'], // focusin: b
].forEach(([el, type]) => {
el.dispatchEvent(new FocusEvent(type, { bubbles: true }));
});

For scroll, the bubbling-sibling approach does not exist. Capture-phase delegation technically works (capture-phase listeners on ancestors do receive scroll events from descendants), but it is rarely a good idea: scroll events fire many times per second, you would receive them for every nested scroller in the subtree, and there is no event.target filtering that beats just attaching a listener directly to the scrollable element. Use one direct listener instead. (window scroll has nothing higher to delegate to anyway.)

Performance: when delegation actually helps

The common claim is "delegation is faster because there are fewer listeners." That is partly true but often overstated.

  • Listener count is often not the bottleneck. Modern browsers handle many listeners efficiently, so measure the actual page rather than relying on a fixed listener-count rule.
  • Memory is the meaningful win. Each direct listener creates a closure that holds references to its enclosing scope. With 10,000 rows, that is 10,000 closures kept alive, which can become noticeable. One delegated listener is one closure.
  • Dynamic content is the structural win. With direct listeners, you have to attach (and detach) listeners on every DOM mutation, which is easy to leak. Delegation just works for elements added later, and that is the main reason most code uses it.
  • Delegation is not always faster at runtime. A delegated handler runs event.target.closest(...) on every event, which is cheap but not free. For a small fixed number of elements with stable handlers, attaching directly is fine and arguably cleaner.

Use delegation for memory, dynamic content, and code simplicity, not because direct listeners are inherently slow.

Pitfalls

Event delegation comes with several pitfalls:

  • Incorrect target handling. Use event.target.closest(selector) rather than checking event.target.tagName directly. Clicks on inner elements (icons, spans) will otherwise miss the match.
  • Not all events bubble. focus, blur, scroll, mouseenter, mouseleave, and resize do not bubble. Use the capture phase or the bubbling alternatives (focusin, focusout, mouseover, mouseout) shown above.
  • stopPropagation() inside the tree breaks delegation. If a child handler calls event.stopPropagation(), the delegated handler at the ancestor never fires. This is a common source of "my handler doesn't run" bugs.
  • Event overhead. Complex routing logic inside the root listener can become hard to maintain. Use small dispatch tables ({ edit: ..., delete: ... }) rather than long if/else chains.

Event delegation in JavaScript frameworks

For many event types, React installs native listeners on the React root and dispatches synthetic events through the component tree. Some event types are handled differently because they do not delegate normally, so this is an implementation detail rather than a guarantee that every React handler maps to one root listener.

When an event occurs, React's event listener captures it and determines which React component rendered the target element based on its internal bookkeeping. React then dispatches the event to the appropriate component's event handler by calling the handler function with a synthetic event object. This synthetic event object wraps the native browser event, providing a consistent interface across different browsers and capturing information about the event.

This lets React provide a consistent event API and centralize much of its event dispatch. Application code should still choose delegation based on the DOM behavior it needs rather than assuming it is always faster.

Further reading

Exercises

Check your understanding
Beta
Check your understanding Exercise 1 of 2
Check your understanding Exercise 1 of 2

A list contains buttons whose labels include nested icons. Buttons can also be added later. Which delegated handler reliably finds the clicked action?