JavaScript Events
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 inlineonclick="…" - ✅ Use event delegation for dynamic / large lists
- ✅ Use
AbortControllerto remove groups of listeners cleanly - ✅ Use
{ passive: true }for scroll-heavy listeners (touchmove,wheel) - ✅ Use
closest()+datasetfor routing inside delegated handlers - ✅ Namespace custom events:
component:action - ❌ Don’t
preventDefaulton 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.