Events and Event Delegation

How a click travels through the DOM, why one listener can serve a thousand elements, and the keyboard events that inline handlers always forget.

Concept

An event is something that happens - a click, a key press, a form submission, an image finishing loading. A listener is a function that runs when it does.

Attaching a listener

button.addEventListener("click", (event) => {
  console.log("clicked", event.target);
});

// with options
el.addEventListener("scroll", onScroll, { passive: true });
el.addEventListener("click", once, { once: true });
el.addEventListener("click", handler, { capture: true });

// removing needs the same function reference
el.removeEventListener("click", handler);

Not inline handlers

<!-- avoid -->
<button onclick="save()">Save</button>

Inline handlers mix behaviour into markup, allow only one handler per event, need the function to be globally available, and are blocked by any reasonable Content Security Policy. addEventListener has none of those limits.

How an event travels

Three phases, and knowing them explains most surprising event behaviour.

1. Capture   document -> html -> body -> div -> button
2. Target    the button itself
3. Bubble    button -> div -> body -> html -> document

Listeners run in the bubble phase by default, which means a click on a button also reaches every ancestor. That is what makes delegation possible.

event.target          // where the event actually started
event.currentTarget   // the element this listener is attached to
event.stopPropagation();   // stop it travelling further
event.preventDefault();    // cancel the default browser behaviour

The distinction between target and currentTarget is the one to internalise. Click an icon inside a button and target is the icon; currentTarget is the button.

Event delegation

Instead of attaching a listener to every item, attach one to their container and work out what was clicked.

<ul id="courses">
  <li><button type="button" data-action="remove" data-id="1">Remove</button></li>
  <li><button type="button" data-action="remove" data-id="2">Remove</button></li>
  <!-- hundreds more -->
</ul>
// one listener, however many buttons
document.getElementById("courses").addEventListener("click", (event) => {
  const button = event.target.closest("[data-action='remove']");
  if (!button) return;

  removeCourse(button.dataset.id);
});

Three benefits, and the third is the important one:

  • One listener instead of hundreds, so less memory.
  • No setup work when the list is rendered.
  • It works for elements added later. A listener attached to an element that did not exist yet is the most common reason dynamic content stops responding.

closest is what makes it robust: a click on an icon inside the button still resolves to the button.

The events worth knowing

EventFires when
clickActivated by mouse, touch, Enter or Space on a real button
inputA field value changes, on every keystroke
changeA field value is committed - on blur, or immediately for checkboxes and selects
submitA form is submitted
focus / blurAn element gains or loses focus. Does not bubble.
focusin / focusoutThe same, but bubbles - use these for delegation
keydown / keyupA key is pressed or released
scrollSomething scrolls. Fires very often.
pointerdown / pointerupMouse, touch and pen unified

Why click is enough on a real button

// a real button: click fires for mouse, touch, Enter and Space
button.addEventListener("click", save);

// a div pretending to be a button: needs all of this
div.addEventListener("click", save);
div.addEventListener("keydown", (event) => {
  if (event.key === "Enter" || event.key === " ") {
    event.preventDefault();
    save();
  }
});
div.tabIndex = 0;
div.setAttribute("role", "button");

And even then the div still lacks disabled state handling, correct focus styling and form participation. This is the concrete argument for using the right element.

Keyboard events

document.addEventListener("keydown", (event) => {
  if (event.key === "Escape") closeDialog();
  if (event.key === "/" && !isTyping(event)) focusSearch();
  if (event.key === "ArrowDown") moveSelection(1);
  if (event.ctrlKey && event.key === "k") openCommandPalette();
});

Use event.key, which gives the character or a name such as Escape, Enter, Tab, ArrowUp. The old keyCode is deprecated and layout dependent.

Debouncing and throttling

Some events fire far more often than any handler needs.

// debounce: run once the activity stops
function debounce(fn, delay = 300) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

searchInput.addEventListener("input", debounce((event) => {
  fetchResults(event.target.value);
}, 300));
// scroll: mark it passive so it never blocks scrolling
window.addEventListener("scroll", () => {
  requestAnimationFrame(updateHeader);
}, { passive: true });

{ passive: true } promises the handler will not call preventDefault, which lets the browser scroll without waiting for it. On touch devices the difference is immediately noticeable.

Custom events

el.dispatchEvent(new CustomEvent("course:selected", {
  detail: { id: 1043 },
  bubbles: true,
}));

document.addEventListener("course:selected", (event) => {
  console.log(event.detail.id);
});

A clean way for parts of a page to communicate without holding references to each other.

Important rules

  • Events bubble by default; focus, blur and a few others do not.
  • event.target is where it started; event.currentTarget is where the listener is.
  • preventDefault cancels the browser behaviour; stopPropagation stops the travel. They are unrelated.
  • A listener added before an element exists never fires for it.
  • Removing a listener requires the same function reference, so an inline arrow function cannot be removed.
  • click on a real button covers mouse, touch and keyboard.

Common mistakes

  • Attaching listeners to elements that will be replaced, then losing them.
  • Using event.target where currentTarget is meant, and matching a child element.
  • Calling stopPropagation everywhere and breaking delegation elsewhere on the page.
  • Click only handlers on non button elements.
  • Heavy work in a scroll or input handler with no throttling.
  • Using keyCode.
  • Inline onclick attributes.

Best practices

  • Delegate to a container for lists and repeated controls.
  • Use closest to resolve the intended target.
  • Use real interactive elements so keyboard support is free.
  • Debounce input handlers, throttle scroll handlers, and mark scroll listeners passive.
  • Prefer event.key and named keys.
  • Use { once: true } for one time handlers.
  • Avoid stopPropagation unless you specifically need it.

Practice

  1. Attach a listener to fifty buttons individually, then replace it with one delegated listener and add a new button dynamically.
  2. Log target and currentTarget for a click on an icon inside a button.
  3. Build a search box that fetches results only after typing stops for three hundred milliseconds.
  4. Make a div behave like a button, then list what still does not work.

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All HTML notes →
HTML

Forms and JavaScript

Read values, submit without a page reload, validate with the built in API, and handle errors accessibly. All of it built on a real form element.

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.