JavaScript Events

5 minute read

An event is a signal — a click, keystroke, form submit, scroll, network completion, custom message — that something happened. You wire up code to react with addEventListener. That single API is the gateway to every interactive piece of a web app. This chapter covers attaching, removing, propagation, delegation, prevention, and custom events.

addEventListener — The Modern API

const btn = document.querySelector("#save");

btn.addEventListener("click", (event) => {
  console.log("clicked at", event.clientX, event.clientY);
});

Signature: target.addEventListener(type, handler, options?).

Arg What
type event name string — "click", "keydown", "submit", …
handler function that receives an Event object
options object: { once, passive, capture, signal }

You can attach multiple handlers for the same event; they fire in registration order.

btn.addEventListener("click", () => console.log("first"));
btn.addEventListener("click", () => console.log("second"));
// click → "first" then "second"

Removing a Listener

The handler reference must match exactly — you can’t remove an anonymous arrow.

function onClick(e) { /* ... */ }

btn.addEventListener("click", onClick);
btn.removeEventListener("click", onClick);    // ✅ same reference

// ❌ This does NOT remove anything — different function
btn.addEventListener("click", () => doX());
btn.removeEventListener("click", () => doX());

Modern alternative: pass an AbortSignal:

const ctrl = new AbortController();

btn.addEventListener("click", onClick, { signal: ctrl.signal });
btn.addEventListener("keydown", onKey, { signal: ctrl.signal });

// Later — remove ALL handlers attached with this signal
ctrl.abort();

This is the cleanest pattern for “remove a whole bunch of listeners when this component unmounts”.

Listener Options

btn.addEventListener("click", handler, {
  once:    true,    // auto-remove after first fire
  passive: true,    // promise NOT to call preventDefault — needed for smooth scroll
  capture: false,   // fire during capture phase (rare — see Propagation below)
  signal:  ctrl.signal,
});

{ passive: true } is important for touchstart, touchmove, wheel — browsers can optimise scroll if they know you won’t block it.

The Event Object

Every handler receives an Event (or subclass). Key fields:

input.addEventListener("input", e => {
  e.type;            // "input"
  e.target;          // the element that fired the event
  e.currentTarget;   // the element on which the listener is attached
  e.timeStamp;       // ms since page load
  e.preventDefault();
  e.stopPropagation();
});

// MouseEvent
btn.addEventListener("click", e => {
  e.clientX; e.clientY;       // viewport coords
  e.button;                   // 0 = left, 1 = middle, 2 = right
  e.shiftKey; e.ctrlKey; e.metaKey; e.altKey;
});

// KeyboardEvent
input.addEventListener("keydown", e => {
  e.key;     // "a", "Enter", "ArrowUp", " " (space), …
  e.code;    // physical: "KeyA", "Enter", "Space"
  e.repeat;  // true if held down
});

Tip: Use event.key (the character you pressed) for app logic; event.code (the physical key, layout-independent) for games or shortcuts.

Common Event Types

Category Events
Mouse click, dblclick, mousedown/up/move, mouseenter/leave, mouseover/out, contextmenu
Keyboard keydown, keyup, keypress (deprecated)
Form submit, reset, input, change, focus, blur
Touch / Pointer touchstart/move/end, pointerdown/up/move
Window load, DOMContentLoaded, resize, scroll, beforeunload
Media play, pause, ended, timeupdate
Network online, offline

Prefer Pointer Events (pointerdown, pointermove, …) over mouse/touch when supporting both touch and mouse — one API, all devices.

Propagation — Bubbling and Capturing

When you click a nested element, the event travels through the DOM in three phases:

1. Capture  (root → target)
2. Target   (the element itself)
3. Bubble   (target → root)
document
  └── body
        └── ul              ← bubbles here
              └── li        ← bubbles here
                    └── span ← click landed here

By default addEventListener listens on the bubble phase — so a click on <span> will trigger handlers on <li>, <ul>, <body>, document in that order. That’s exactly the behaviour event delegation exploits.

Capture phase listeners get the event first (use { capture: true }). Rare in app code.

parent.addEventListener("click", () => console.log("parent"));
child.addEventListener("click",  () => console.log("child"));

// Click child → logs:
// child
// parent

To stop further propagation:

child.addEventListener("click", e => {
  e.stopPropagation();      // skip parent / ancestors
  // e.stopImmediatePropagation();  // ALSO skip remaining handlers on same element
});

Event Delegation

Instead of attaching a listener to every list item, attach one to the parent and inspect event.target. Massive perf and simplicity win for big lists or dynamic content.

const list = document.querySelector("#todo-list");

list.addEventListener("click", (e) => {
  const li = e.target.closest("li");
  if (!li || !list.contains(li)) return;

  const id = li.dataset.id;
  if (e.target.matches(".delete-btn")) {
    removeTodo(id);
  } else if (e.target.matches("input[type='checkbox']")) {
    toggleTodo(id, e.target.checked);
  }
});

closest walks up until it finds a matching ancestor. Pattern: listen on container, route by target.

preventDefault — Block the Browser Default

Stops the browser’s built-in behaviour. The handler still runs; propagation still happens (unless you also stopPropagation).

// Stop a form from navigating away
form.addEventListener("submit", e => {
  e.preventDefault();
  sendAjax(new FormData(form));
});

// Stop a link from following its href
link.addEventListener("click", e => {
  e.preventDefault();
  navigateInRouter(link.href);
});

// Stop Ctrl+S from saving the page
window.addEventListener("keydown", e => {
  if (e.ctrlKey && e.key === "s") {
    e.preventDefault();
    saveDocument();
  }
});
Method Stops default action? Stops propagation? Stops other listeners on same element?
preventDefault() ✅ ❌ ❌
stopPropagation() ❌ ✅ ❌
stopImmediatePropagation() ❌ ✅ ✅
return false from inline handler ✅ ✅ ❌

Custom Events

Dispatch your own events for component-to-component messaging:

const e = new CustomEvent("user:loaded", {
  detail: { id: 42, name: "Ada" },
  bubbles: true,
});
element.dispatchEvent(e);

// Elsewhere
document.addEventListener("user:loaded", e => {
  console.log("got user", e.detail);
});

Namespace your custom event names (user:loaded, cart:itemAdded) to avoid colliding with future built-ins.

A Realistic Example — Form Validation

const form  = document.querySelector("#signup");
const email = form.querySelector("[name=email]");
const error = form.querySelector(".error");

email.addEventListener("blur", () => {
  validateEmail();
});

form.addEventListener("submit", (e) => {
  e.preventDefault();
  if (!validateEmail()) return;

  // proceed with the submission
  console.log("submitting", new FormData(form));
});

function validateEmail() {
  const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value.trim());
  email.classList.toggle("invalid", !ok);
  error.textContent = ok ? "" : "Please enter a valid email";
  return ok;
}

blur for live feedback, submit for the final check, preventDefault to block the default navigation, classes for styling.

Common Mistakes

// ❌ Inline handlers in HTML — hard to manage, security risk
<button onclick="doIt()">

// ✅ Attach with addEventListener

// ❌ Anonymous handler then trying to remove it
btn.addEventListener("click", () => doThing());
btn.removeEventListener("click", () => doThing()); // different function!

// ✅ Save the reference
const handler = () => doThing();
btn.addEventListener("click", handler);
btn.removeEventListener("click", handler);

// ❌ Forgetting to preventDefault on form submit
form.addEventListener("submit", () => sendAjax());
// → page navigates away anyway

// ❌ Listening on every list item (slow for huge lists)
items.forEach(li => li.addEventListener("click", onClick));
// ✅ Delegate from the container

// ❌ Memory leak — listener on long-lived element with closure over component data
window.addEventListener("resize", onResize);
// → never removed → leak

Best Practices Checklist

  • ✅ Always addEventListener; never inline onclick="…"
  • ✅ Use event delegation for dynamic / large lists
  • ✅ Use AbortController to remove groups of listeners cleanly
  • ✅ Use { passive: true } for scroll-heavy listeners (touchmove, wheel)
  • ✅ Use closest() + dataset for routing inside delegated handlers
  • ✅ Namespace custom events: component:action
  • ❌ Don’t preventDefault on passive listeners — it’s silently ignored (with a warning)
  • ❌ Don’t forget to remove listeners when their owner goes away
  • ❌ Don’t depend on event.target === event.currentTarget — use it deliberately

What’s Next?

Events are inherently asynchronous — they happen “later”. To really understand timing, callbacks, and setTimeout, you need to see how the event loop schedules JS work. That’s next.

← Previous: JS DOM Manipulation Next: JS Event Loop →

Updated: