Skip to content
elephantoo

Events & delegation

Lesson 14 of 34 16 min read

The event object, bubbling and capturing, delegation, removing listeners and keyboard events.


Events are how the browser tells your code that something happened: a click, a key press, a form submission, the page finishing loading. In this lesson you'll learn the event object, how events travel through the DOM, and the delegation pattern used by every serious front-end codebase.

Listening and the event object#

JavaScript
const button = document.querySelector("#save");

function handleClick(event) {
  console.log(event.type);          // "click"
  console.log(event.target);        // the element that was clicked
  console.log(event.clientX, event.clientY); // pointer position
  console.log(event.timeStamp);     // ms since page load
}

button.addEventListener("click", handleClick);

Every handler receives an event object with details about what happened. Its properties depend on the event type:

EventUseful properties
click, pointerdownclientX, clientY, button, shiftKey, ctrlKey
keydown, keyupkey, code, repeat, metaKey, altKey
input, changetarget.value, target.checked
submittarget (the form), submitter (the button)

Common event types

  • Mouse/pointer: click, dblclick, pointerdown, pointerup, pointermove, pointerenter, pointerleave, contextmenu
  • Keyboard: keydown, keyup
  • Form: input (every change), change (committed change), submit, focus, blur, focusin, focusout
  • Page/window: DOMContentLoaded, load, resize, scroll, visibilitychange, beforeunload

Prefer pointer events over mouse events — they work for mouse, touch and pen.

Keyboard events#

JavaScript
document.addEventListener("keydown", (event) => {
  if (event.key === "Escape") {
    console.log("Close the dialog");
  }
  if ((event.ctrlKey || event.metaKey) && event.key === "k") {
    event.preventDefault(); // stop the browser's own shortcut
    console.log("Open the search palette");
  }
});

event.key is the character or key name ("a", "A", "Enter", "ArrowUp"); event.code is the physical key ("KeyA"), which ignores keyboard layout. Use key for shortcuts and text, code for games.

Preventing default behaviour#

Some events have built-in browser behaviour: links navigate, forms submit and reload the page, Ctrl+S saves. event.preventDefault() cancels it:

JavaScript
const link = document.querySelector("a.external");
link.addEventListener("click", (event) => {
  if (!confirm("Leave Elephantoo?")) {
    event.preventDefault();
  }
});

How events travel: capture, target, bubble#

When you click a <span> inside a <button> inside a <li>, the event doesn't just happen on the span. It travels in three phases:

  1. Capture — from window down through each ancestor to the target.
  2. Target — at the element that was clicked.
  3. Bubble — back up from the target through each ancestor to window.

Listeners run in the bubble phase by default:

HTML
<ul id="menu">
  <li><button><span>Home</span></button></li>
</ul>
JavaScript
const menu = document.querySelector("#menu");
const btn = document.querySelector("#menu button");
const span = document.querySelector("#menu span");

span.addEventListener("click", () => console.log("span"));
btn.addEventListener("click", () => console.log("button"));
menu.addEventListener("click", () => console.log("ul"));
menu.addEventListener("click", () => console.log("ul (capture)"), { capture: true });

Clicking "Home" logs:

Output
ul (capture)
span
button
ul

target vs currentTarget

JavaScript
menu.addEventListener("click", (event) => {
  console.log(event.target.tagName);        // SPAN – where the click started
  console.log(event.currentTarget.tagName); // UL – whose listener is running
});

Stopping propagation

event.stopPropagation() stops the event travelling further. Use it sparingly — it breaks delegation and analytics code higher up that expects to see the event. There's usually a better design.

Event delegation#

Instead of adding a listener to every item — including ones created later — add one listener to a common parent and figure out which child was involved:

HTML
<ul id="todo-list">
  <li data-id="1">Learn events <button class="delete">✕</button></li>
  <li data-id="2">Practise delegation <button class="delete">✕</button></li>
</ul>
JavaScript
const list = document.querySelector("#todo-list");

list.addEventListener("click", (event) => {
  const deleteBtn = event.target.closest(".delete");
  if (deleteBtn) {
    const li = deleteBtn.closest("li");
    console.log(`Deleting todo ${li.dataset.id}`);
    li.remove();
    return;
  }

  const li = event.target.closest("li");
  if (li && list.contains(li)) {
    li.classList.toggle("done");
  }
});

// Items added later work automatically:
const li = document.createElement("li");
li.dataset.id = "3";
li.append("New item ", Object.assign(document.createElement("button"), { className: "delete", textContent: "✕" }));
list.append(li);

Why delegation is great:

  • One listener instead of hundreds — less memory, faster setup.
  • Dynamic content just works — no need to attach listeners to new elements.
  • Pairs perfectly with data-* attributes to identify items.

closest(selector) walks up from the clicked element (the target could be a nested icon or text) to the element you care about.

Listener options#

JavaScript
const banner = document.querySelector("#banner");
const container = document.querySelector("#feed");

banner.addEventListener("click", () => console.log("Shown once"), { once: true });

container.addEventListener("wheel", () => { /* read-only */ }, { passive: true });
  • once: true — the listener removes itself after the first call.
  • passive: true — promises you won't call preventDefault(), letting the browser scroll smoothly.
  • capture: true — listen during the capture phase.
  • signal — remove the listener with an AbortController (below).

Removing listeners#

To remove a listener you need the same function reference:

JavaScript
function onResize() {
  console.log(window.innerWidth);
}
window.addEventListener("resize", onResize);
window.removeEventListener("resize", onResize); // ✅ same reference

To remove several at once, use an AbortController:

JavaScript
const controller = new AbortController();
const { signal } = controller;

window.addEventListener("resize", () => console.log("resize"), { signal });
document.addEventListener("keydown", () => console.log("key"), { signal });

// Later – e.g. when a widget is closed:
controller.abort(); // removes both listeners

Throttling busy events#

scroll, resize, pointermove and input can fire dozens of times per second. Debouncing waits until the events stop:

JavaScript
function debounce(fn, delay = 300) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

const search = document.querySelector("#search");
search.addEventListener(
  "input",
  debounce((event) => console.log("Searching for", event.target.value), 400)
);

Now the search only runs 400 ms after the user stops typing. (This debounce is a closure — the timer variable is remembered between calls.)

Custom events#

Components can announce their own events:

JavaScript
const cart = document.querySelector("#cart");

cart.addEventListener("cart:add", (event) => {
  console.log(`Added ${event.detail.name}`);
});

cart.dispatchEvent(
  new CustomEvent("cart:add", { detail: { name: "Notebook" }, bubbles: true })
);

Waiting for the page#

With defer or type="module" scripts, the DOM is ready when your code runs. For inline scripts in <head>, wait for DOMContentLoaded:

JavaScript
document.addEventListener("DOMContentLoaded", () => {
  console.log("DOM ready");
});

Common mistakes#

  • Writing button.addEventListener("click", handleClick()) — that calls handleClick immediately. Pass handleClick.
  • Using event.target when the click landed on a nested element — use closest().
  • Adding listeners inside a function that runs repeatedly, stacking duplicates.
  • Overusing stopPropagation().
  • Relying on click handlers on <div>s — use real <button>s so keyboard and screen-reader users can activate them.

What's next#

Next: forms and validation — reading user input, the browser's built-in validation and writing your own checks.

Check your understanding

Quick quiz

0/3 answered
  1. 1.In event delegation, where do you attach the listener?

  2. 2.What is the difference between event.target and event.currentTarget?

  3. 3.Why does button.removeEventListener("click", () => save()) fail to remove a listener added with addEventListener("click", () => save())?

Finished reading?

Mark this lesson complete to track your progress.