FrontendPro

22Implement event delegation for a dynamic list

easyAbout 15 minutesCodingFreedomeventsperformance

Problem

A to-do list adds and removes <li class="item"> elements all the time. Attaching a click listener to every item is slow and easy to get wrong, because new items need listeners too.

Implement delegate(root, eventType, selector, handler). It adds one listener on root. When an event happens on an element inside root that matches selector (or on anything inside such an element), it calls handler(event, matchedElement) with this set to the matched element. It returns a function that removes the listener.

const off = delegate(list, "click", ".item", (event, item) => {
  console.log("clicked", item.dataset.id);
});
list.insertAdjacentHTML("beforeend", '<li class="item" data-id="3">New</li>');
// Clicking the new item works without adding a listener.
off(); // stop listening

Requirements

  • Items added after delegate was called must work.
  • Clicking a child of an item, such as a <span> inside the <li>, counts as clicking the item.
  • Ignore events that do not come from a matching element.
  • Ignore matches outside root, even if an ancestor of root matches the selector.

Try it yourself

Write your solution, run it, then submit to check it against the tests.

App.jsxReact + DOM

Video walkthroughs

Stuck? Watch someone solve it, then try again on your own.

Event Delegation in Javascript | UI/Frontend Interview QuestionAkshay Saini on YouTubeOpen on YouTube
BFE.dev 117. event delegation | JSer - Front-End Interview questionsJSer on YouTubeOpen on YouTube

Step by step

Build the solution one idea at a time.

  1. 1

    Listen once, on the container

    Events bubble: a click on an <li> also fires on its <ul> and every ancestor. One listener on root therefore sees clicks on every current and future item.

  2. 2

    Find the matching element

    event.target is the innermost element that was clicked, perhaps a <span> inside the item. event.target.closest(selector) walks up from there and returns the nearest ancestor (or the target itself) that matches.

  3. 3

    Make sure the match is inside root

    closest keeps walking past root. If root itself sits inside an element that matches, a click on a non-item would find that outer element. Check root.contains(match) and ignore anything outside.

  4. 4

    Call the handler with the right this

    handler.call(match, event, match) gives the handler both the event and the matched element, and sets this like a normal listener on that element would.

  5. 5

    Return a cleanup function

    Keep a reference to the listener and return () => root.removeEventListener(eventType, listener). Callers, including React effects, need a way to unsubscribe.

Approach

Attach one listener to the container and use closest to translate the raw event target into the item the user meant, ignoring matches outside the container.

Solution

function delegate(root, eventType, selector, handler) {
  function listener(event) {
    const target = event.target instanceof Element ? event.target : event.target.parentElement;
    const match = target?.closest(selector);
    if (match && root.contains(match)) {
      handler.call(match, event, match);
    }
  }
  root.addEventListener(eventType, listener);
  return () => root.removeEventListener(eventType, listener);
}

// Try it: press Run, then click the items in the preview.
const list = document.getElementById("root");
list.innerHTML = '<ul><li class="item" data-id="1"><b>Buy milk</b></li><li class="item" data-id="2">Call mum</li></ul>';
delegate(list, "click", ".item", (event, item) => {
  item.style.textDecoration = "line-through";
});

Complexity

Time: O(d) per event, where d is the distance from the target up to root. Space: O(1): one listener, whatever the number of items.

Edge cases

  • Items added later work, because nothing is attached to the items.
  • A click on a nested element finds its item through closest.
  • A click on empty space inside root calls nothing.
  • An ancestor of root that matches the selector is ignored.
  • If the target is a text node, start from its parent element.

Follow-up questions

Answer each one out loud first, then reveal the model answer.