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 method | What it returns | Role in delegation |
|---|---|---|
| event.target | Innermost element that dispatched the event | Starting point for matching |
| event.currentTarget | Element that holds the listener | Defines the delegation boundary |
| Element.closest() | Nearest ancestor matching a selector, or null | Maps a nested click to its item |
| Node.contains() | Whether a node lies inside another | Rejects matches outside the container |
| event.composedPath() | Full propagation path as an array | Handles shadow DOM retargeting |
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 }.
| Event | Bubbles | Delegation approach |
|---|---|---|
| click, input, change | Yes | Standard bubbling listener |
| keydown, keyup | Yes | Standard bubbling listener |
| submit | Yes | Listener on a form container |
| focus, blur | No | Use focusin and focusout, or capture |
| mouseenter, mouseleave | No | Use mouseover and mouseout with checks |
| load, error on images | No | Capture-phase listener on the ancestor |
| scroll on elements | No | Listener on the scrolling element itself |
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.
| Aspect | Per-element listeners | Event delegation |
|---|---|---|
| Number of listeners | One per element | One per container |
| Dynamic content | Must rebind after each insertion | Works automatically |
| Memory and setup cost | Grows with element count | Roughly constant |
| Handler complexity | Simple, no matching | Requires selector matching |
| Non-bubbling events | Supported directly | Needs capture or equivalents |
| stopPropagation in children | No effect on the handler | Can silently block delegation |
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.
- Attach one listener to the closest stable container, not to
document, unless the behavior is truly global. - Find the logical item with
event.target.closest('[data-action]')or a similar data attribute selector. - Return early when there is no match or when
container.contains(match)is false. - Read the intended action from a data attribute, such as
data-action="delete", rather than from class names used for styling. - 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.
| Situation | Recommended container | Reason |
|---|---|---|
| Sortable data table | The tbody element | Rows change, the body persists |
| Infinite feed | The feed wrapper | Items are appended continuously |
| Site-wide tooltips | The document | Triggers appear anywhere |
| Widget inside a component | The component root | Keeps behavior encapsulated |
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.