Articles

JavaScript Event Delegation Explained

Event delegation attaches one listener to a parent instead of one per child, using event bubbling to catch clicks from elements added after page load.

Takina Takina · · 4 min read
Code editor showing component source

Event delegation is a pattern where you attach a single event listener to a parent element instead of separate listeners to each of its children, relying on event bubbling to catch interactions as they propagate up. Instead of wiring up a click handler on every row in a table, you put one listener on the table itself and inspect event.target to figure out which row was clicked. It’s fewer listeners, less memory, and it keeps working for elements that don’t exist yet.

How bubbling makes this possible

When you click an element in the DOM, the browser doesn’t just fire the event on that element — it fires it on the element first (capturing phase, rarely used directly), then on the element itself (target phase), then back up through every ancestor in turn (bubbling phase). By default, addEventListener listens during the bubbling phase, so a listener on document.body will fire for a click on any element inside it, unless something explicitly stops propagation with event.stopPropagation().

Delegation exploits this directly: put the listener on a stable ancestor, and let clicks on any descendant bubble up to it.

document.querySelector('#todo-list').addEventListener('click', (event) => {
  const item = event.target.closest('.todo-item');
  if (!item) return;
  item.classList.toggle('done');
});

event.target is the actual element that was clicked — which might be a <span> inside a <li>, not the <li> itself. .closest() walks up from the target to find the nearest ancestor matching a selector, which is why it’s the standard way to identify “which logical item did this click belong to” regardless of which nested element actually received the click.

Why this matters for dynamic content

The biggest practical win is handling elements that don’t exist at the time the listener is attached. If you attach a click handler to each .todo-item individually, any item added later — by a fetch response, a form submission, user input — has no listener until you remember to add one. With delegation, the listener lives on the parent and was never tied to specific children, so newly inserted items are handled automatically the first time they’re clicked. This eliminates an entire class of “why doesn’t this button work” bugs caused by listeners being attached before the element existed.

Fewer listeners, less memory

Attaching a thousand listeners to a thousand list items costs real memory and setup time, especially if the list is re-rendered often. One delegated listener on the container replaces all of them. This matters more as lists grow — a data table with dynamic rows, a comment thread, an infinite-scroll feed — where the number of interactive elements can be large and constantly changing. It also simplifies cleanup: removing the list’s DOM subtree removes all the “listeners” implicitly, since there was only ever one, attached to the container.

When delegation doesn’t apply

Not every event bubbles. focus and blur fire only on the target element by default — the bubbling equivalents are focusin and focusout, which delegation should use instead. Events like mouseenter and mouseleave also don’t bubble (their bubbling counterparts are mouseover and mouseout, which behave differently around child elements). Always check whether the specific event you’re using has a non-bubbling default before assuming delegation will work.

Delegation is also the wrong fit when you need capture-phase behavior or when stopping propagation on a specific inner element is part of the design — those patterns work against, not with, the idea of one shared listener higher up the tree.

Delegation vs one listener per element

Per-element listenersDelegated listener
Setup costGrows with element countConstant, one listener
Handles dynamic contentNo — needs re-bindingYes, automatically
MemoryHigher for large listsLower
CleanupMust remove each listenerRemoving the container is enough
Needs event.target inspectionNoYes

Framework context

Modern frameworks like React already delegate under the hood — a single listener attached near the root handles most events for the whole component tree, and the framework’s synthetic event system dispatches to the right component handler internally. Understanding manual delegation is still useful for vanilla JS, web components, and any code working directly with the DOM rather than through a framework’s abstraction. It’s also a useful lens for reasoning about the JavaScript event loop — delegated handlers are ordinary callbacks scheduled the same way any other DOM event is.

The takeaway

Event delegation attaches one listener to a shared ancestor instead of one per child, relying on bubbling and event.target.closest() to identify what was actually clicked. It automatically covers elements added after the listener was set up, uses less memory on large or dynamic lists, and simplifies cleanup — but it only works for events that bubble, so check that before reaching for it on focus, blur, or the mouse-enter family.

Takina Takina · · 5 min read

Promise.all() vs allSettled() vs race() Compared

Promise.all() fails fast, allSettled() waits for every result, and race() returns whichever promise finishes first — how to choose correctly.

#JavaScript #Web Development #Frontend
Takina Takina · · 4 min read

JavaScript Spread vs Rest Operators, Explained

The spread operator (...) expands an iterable into individual elements; the rest operator collects elements back into an array. Same syntax, opposite jobs.

#JavaScript #Web Development #Frontend
Takina Takina · · 4 min read

ResizeObserver API Explained: Watching Element Size

The ResizeObserver API lets JavaScript watch an element's box size and react without polling or resize-event hacks. How it works and when to use it.

#Web Development #JavaScript #Frontend