Event delegation

On this page 7

Event delegation is a pattern in browser JavaScript in which a single event listener on a common ancestor handles events that originate on many descendant elements, instead of attaching a separate listener to each element. It relies on the fact that most DOM events propagate upward through the tree, so the ancestor sees every click, input or keydown that happens inside it and can decide which descendant was the real target.

The pattern matters most for long or dynamic lists, tables, menus and any interface where elements are added and removed after the page loads. One delegated listener keeps working for elements that did not exist when it was registered, uses less memory than hundreds of individual handlers and removes the need to rebind behavior after every re-render.

How event delegation uses event propagation

Event delegation works because a DOM event travels in three phases: the capture phase from the window down to the target, the target phase at the element that triggered the event, and the bubbling phase back up to the window. A listener registered on an ancestor with addEventListener runs during bubbling by default, after the descendant has already received the event.

Inside the handler, event.target identifies the innermost element that was actually hit, while event.currentTarget is the element that owns the listener. The handler therefore inspects the target, usually with event.target.closest(selector), to find the nearest matching descendant, and then confirms that the match is still inside the delegating container with container.contains(match).

The following table summarizes the properties and methods that a delegated handler relies on and what each one contributes to the matching logic.

Property or methodWhat it returnsRole in delegation
event.targetInnermost element that dispatched the eventStarting point for matching
event.currentTargetElement that holds the listenerDefines the delegation boundary
Element.closest()Nearest ancestor matching a selector, or nullMaps a nested click to its item
Node.contains()Whether a node lies inside anotherRejects matches outside the container
event.composedPath()Full propagation path as an arrayHandles shadow DOM retargeting
Building blocks of a delegated handler

Which events can be delegated

Delegation only works for events that bubble or that can be intercepted in the capture phase. Most user-input events bubble, but several focus, loading and pointer-boundary events do not, and a delegated listener for them must either use a bubbling equivalent or register with { capture: true }.

EventBubblesDelegation approach
click, input, changeYesStandard bubbling listener
keydown, keyupYesStandard bubbling listener
submitYesListener on a form container
focus, blurNoUse focusin and focusout, or capture
mouseenter, mouseleaveNoUse mouseover and mouseout with checks
load, error on imagesNoCapture-phase listener on the ancestor
scroll on elementsNoListener on the scrolling element itself
Bubbling behavior of common DOM events

The mouseover and mouseout pair deserves extra care: these events fire every time the pointer crosses into a child element, so a delegated hover handler must compare event.relatedTarget with the matched item to avoid reacting repeatedly while the pointer moves inside the same row.

Event delegation compared with direct listeners

Event delegation is an alternative to attaching an event listener to every element, and each approach has a clear profile. Direct listeners are simpler to read and receive the element as currentTarget without any matching, while delegation centralizes behavior and scales with constant listener count.

AspectPer-element listenersEvent delegation
Number of listenersOne per elementOne per container
Dynamic contentMust rebind after each insertionWorks automatically
Memory and setup costGrows with element countRoughly constant
Handler complexitySimple, no matchingRequires selector matching
Non-bubbling eventsSupported directlyNeeds capture or equivalents
stopPropagation in childrenNo effect on the handlerCan silently block delegation
Delegated versus per-element listeners

Delegation should also be distinguished from event bubbling itself. Bubbling is the browser mechanism; delegation is the design decision to exploit that mechanism. Frameworks such as React have long used a form of delegation internally, attaching a small number of root listeners and dispatching to components through their own synthetic event system.

Implementing delegation in practice

A robust delegated handler follows a short, repeatable sequence. The container must be stable for the lifetime of the feature, because removing or replacing it removes the listener as well.

  1. Attach one listener to the closest stable container, not to document, unless the behavior is truly global.
  2. Find the logical item with event.target.closest('[data-action]') or a similar data attribute selector.
  3. Return early when there is no match or when container.contains(match) is false.
  4. Read the intended action from a data attribute, such as data-action="delete", rather than from class names used for styling.
  5. Dispatch to a small map of named functions so the handler stays readable as actions grow.

Choosing the right container is a trade-off between reach and precision. Delegating to document catches everything but runs the handler for every click on the page, while delegating to a list element keeps the work local and makes the boundary obvious to other developers.

SituationRecommended containerReason
Sortable data tableThe tbody elementRows change, the body persists
Infinite feedThe feed wrapperItems are appended continuously
Site-wide tooltipsThe documentTriggers appear anywhere
Widget inside a componentThe component rootKeeps behavior encapsulated
Choosing the delegation container

Pitfalls and common mistakes

Most delegation bugs come from incorrect target matching. Comparing event.target directly with a list item fails as soon as the user clicks an icon or a span inside it, which is why closest() is the safer default.

  • Nested interactive elements: a button inside a clickable row can trigger both actions unless the handler checks the most specific match first.
  • stopPropagation in child code: a third-party widget that stops propagation prevents the event from ever reaching the delegated listener.
  • Shadow DOM retargeting: events leaving a shadow root report the host as the target, so matching inside web components needs composedPath().
  • Disabled elements: clicks on disabled form controls may not dispatch at all, which can surprise code that expects a delegated handler to run.
  • Accessibility gaps: delegating clicks on non-interactive elements such as div rows does not make them keyboard accessible; real buttons and links remain necessary.

Performance claims also need nuance. Delegation reduces listener count and setup time, which matters for thousands of elements, but it does not make individual handlers faster. For a handful of static buttons, direct listeners remain perfectly reasonable and are often clearer.

A non-technical view

Imagine a large apartment building where every resident could hire a personal doorman. That would be expensive, and every new resident would need a new doorman on the day they move in. Instead, the building has one reception desk at the entrance. Every visitor passes the desk, says which apartment they want, and the receptionist sends them to the right door.

Event delegation works the same way on a web page. Rather than giving each button its own helper, the page places one helper on the area that contains them all. When someone clicks, the helper checks which item was clicked and does the right thing, even for items that appeared later.

A concrete example

A team building an internal ticketing dashboard displays a table of up to 5,000 support tickets, each row with buttons to assign, close or escalate. The first version attached three click listeners to every row, which meant 15,000 listeners and noticeable delay each time filters re-rendered the table, plus bugs when newly loaded rows had no behavior at all.

The developers replaced this with one click listener on the table body. Each button carries a data-action attribute and the row carries a data-ticket-id. The handler finds the button with closest('[data-action]'), reads the ticket identifier from the parent row and calls the matching function. Filtering and pagination now simply replace the rows, and every new row works immediately without rebinding. The setup cost after each re-render dropped to almost nothing, and the class of bugs caused by missing listeners disappeared.