Event delegation in the browser using vanilla JavaScript

By

Published · Updated

Tagged: JavaScript

Learn how to do event delegation in the browser with vanilla JavaScript, building an on() helper so one listener handles dynamically added child elements.

One of my favorite things from jQuery is event delegation. I still like it. In particular the .on() method.

We select a DOM element, and then we use .on() to attach an event handler that’s executed on a particular child of that element.

Why is this useful? Because if you’re adding elements dynamically to the DOM, a single event listener registered through .on() will work on all children, even the ones that are added to the DOM after you registered the event handler.

Suppose you have a table. Inside the table, we have a set of rows, and each row has an button with a click handler.

You register an event listener when the DOM loads:

document.addEventListener('DOMContentLoaded', () => {
  const buttons = document.querySelectorAll('button')

  for (const button of buttons) {
    button.addEventListener(...)
  }
})

But if we add a new row to the table, we must then also remember to register a new event listener.

How can we replicate the same functionality using vanilla JavaScript?

We create a on function that takes a wrapper selector, an event type (a 'click' string for example), a child selector string, that will match descendants of the wrapper selector. In this function, we first create a loop, and we add an event listener to each element that matches our wrapper selector (so it can apply to more than one wrapper selector).

Delegation works because events bubble. A click on a child reaches the parent, so one listener can handle many items, including ones added later.

Do not test event.target with matches() alone. If the matching element has children, a click on the inner element makes target that child, and the handler never fires. Use closest() to walk up to the matching ancestor, then check that it still sits inside the wrapper:

const on = (selector, eventType, childSelector, eventHandler) => {
  const elements = document.querySelectorAll(selector)
  for (const element of elements) {
    element.addEventListener(eventType, eventOnElement => {
      const target = eventOnElement.target.closest(childSelector)
      if (target && element.contains(target)) {
        eventHandler(eventOnElement, target)
      }
    })
  }
}

This is how we can invoke this function:

on('ul', 'click', '.module.complete', (event, item) => {
  //item is the matched .module.complete, not necessarily event.target
})

Now when we click on an element that matches .module.complete under the ul selector, including a click on a child inside it, the handler runs. item is the matched element.

Here is the same idea on a real list. One listener on ul handles every .done button, including ones you add later:

<ul id="todos">
  <li><button class="done">Done</button> Buy milk</li>
  <li><button class="done">Done</button> Walk the dog</li>
</ul>
on('#todos', 'click', '.done', (event, button) => {
  button.closest('li').classList.add('complete')
})

Add a third item after the listener is already there:

const list = document.getElementById('todos')
const item = document.createElement('li')
item.innerHTML = '<button class="done">Done</button> Water the plants'
list.append(item)

Clicking Done on the new row still runs the handler. You never attach a listener to that button.

Be careful: this needs a bubbling event. focus and blur do not bubble, so delegation with on() will not see them. Use focusin / focusout instead.

Want me to talk about your product? You can sponsor this site.

Related posts about js: