Events
JavaScript in the browser is event-driven — code runs in response to user actions, network responses, timers, and DOM changes. The principal API: element.addEventListener(type, handler, options). The Event object passed to handlers admits substantial properties (target, currentTarget, type, timeStamp) and methods (preventDefault, stopPropagation, stopImmediatePropagation). Events propagate in three phases — capture (down), target (at element), bubble (up). The conventional contemporary patterns: event delegation (single listener on common ancestor), AbortController (substantial removal), custom events (CustomEvent with dispatchEvent). The combination — substantial event surface, the propagation mechanism, the substantial delegation pattern, the conventional removal mechanism — is the substance of DOM event work.
Adding listeners
const button = document.querySelector("button");
button.addEventListener("click", (e) => {
console.log("clicked", e);
});
// With options:
button.addEventListener("click", handler, {
once: true, // remove after first call
passive: true, // cannot preventDefault
capture: true, // capture phase (default: bubble)
signal: controller.signal // for AbortController
});
Removing listeners
function handler(e) { ... }
button.addEventListener("click", handler);
button.removeEventListener("click", handler);
// Cannot remove anonymous functions; the reference must match.
The conventional contemporary pattern uses AbortController:
const controller = new AbortController();
button.addEventListener("click", handler, { signal: controller.signal });
input.addEventListener("input", anotherHandler, { signal: controller.signal });
// Remove all at once:
controller.abort();
The AbortController admits substantial cleaner code — substantial multiple removal at once.
The Event object
button.addEventListener("click", (e) => {
e.target; // the element clicked
e.currentTarget; // the element with the listener
e.type; // "click"
e.timeStamp; // ms since page load
e.preventDefault(); // cancel default action
e.stopPropagation(); // stop propagating
e.stopImmediatePropagation(); // also other handlers on same el
e.defaultPrevented; // boolean
e.bubbles; // does this event bubble?
e.cancelable; // can preventDefault?
e.composedPath(); // array of ancestors (incl. shadow)
});
Event propagation
Events go through three phases:
- Capture phase — from
windowdown to target. - Target phase — at the element.
- Bubble phase — from target back up to
window.
parent.addEventListener("click", () => console.log("parent bubble"));
parent.addEventListener("click", () => console.log("parent capture"), true);
child.addEventListener("click", () => console.log("child"));
// Click on child logs:
// parent capture
// child
// parent bubble
The conventional discipline uses bubble phase (default); capture is reserved for substantial special cases.
// Stop propagation:
e.stopPropagation(); // ancestors don't fire
// Stop other handlers on same element:
e.stopImmediatePropagation();
Common event types
Mouse events
el.addEventListener("click", handler);
el.addEventListener("dblclick", handler);
el.addEventListener("mousedown", handler);
el.addEventListener("mouseup", handler);
el.addEventListener("mouseenter", handler); // does NOT bubble
el.addEventListener("mouseleave", handler); // does NOT bubble
el.addEventListener("mouseover", handler); // bubbles
el.addEventListener("mouseout", handler); // bubbles
el.addEventListener("mousemove", handler);
el.addEventListener("contextmenu", handler); // right-click
// MouseEvent properties:
e.clientX; // viewport coords
e.clientY;
e.pageX; // document coords
e.pageY;
e.offsetX; // relative to target
e.offsetY;
e.button; // 0=left, 1=middle, 2=right
e.buttons; // bitmask of pressed
e.shiftKey;
e.ctrlKey;
e.metaKey;
e.altKey;
Keyboard events
el.addEventListener("keydown", handler);
el.addEventListener("keyup", handler);
// keypress is deprecated — avoid
input.addEventListener("keydown", (e) => {
e.key; // "a", "Enter", "ArrowDown", " "
e.code; // "KeyA", "Enter", "Space" (physical)
e.shiftKey;
e.ctrlKey;
e.metaKey;
e.altKey;
e.repeat; // is this a repeat?
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
});
Form events
input.addEventListener("input", handler); // every keystroke
input.addEventListener("change", handler); // on commit/blur
input.addEventListener("focus", handler); // does NOT bubble
input.addEventListener("blur", handler); // does NOT bubble
input.addEventListener("focusin", handler); // bubbles
input.addEventListener("focusout", handler); // bubbles
form.addEventListener("submit", (e) => {
e.preventDefault();
const data = new FormData(form);
submit(data);
});
form.addEventListener("reset", handler);
select.addEventListener("change", (e) => {
console.log(e.target.value);
});
Touch events
el.addEventListener("touchstart", handler, { passive: true });
el.addEventListener("touchmove", handler, { passive: true });
el.addEventListener("touchend", handler);
el.addEventListener("touchcancel", handler);
el.addEventListener("touchstart", (e) => {
e.touches; // all current touches
e.targetTouches; // touches on this element
e.changedTouches; // touches that changed
const touch = e.touches[0];
touch.clientX;
touch.clientY;
touch.identifier;
});
For substantial mobile work, the Pointer Events API admits unified mouse/touch/pen:
el.addEventListener("pointerdown", handler);
el.addEventListener("pointermove", handler);
el.addEventListener("pointerup", handler);
el.addEventListener("pointercancel", handler);
el.addEventListener("pointerdown", (e) => {
e.pointerId; // unique pointer ID
e.pointerType; // "mouse", "pen", "touch"
e.pressure;
e.tangentialPressure;
e.tiltX;
e.tiltY;
e.twist;
e.width;
e.height;
e.isPrimary;
});
Scroll and wheel events
window.addEventListener("scroll", handler, { passive: true });
el.addEventListener("scroll", handler, { passive: true });
el.addEventListener("wheel", (e) => {
e.deltaX;
e.deltaY;
e.deltaZ;
e.deltaMode; // 0=pixel, 1=line, 2=page
});
The conventional discipline marks scroll/wheel/touch as { passive: true } — admit substantial scrolling performance.
Drag and drop
draggable.draggable = true;
draggable.addEventListener("dragstart", (e) => {
e.dataTransfer.setData("text/plain", "data");
e.dataTransfer.effectAllowed = "move";
});
target.addEventListener("dragover", (e) => {
e.preventDefault(); // required to allow drop
e.dataTransfer.dropEffect = "move";
});
target.addEventListener("drop", (e) => {
e.preventDefault();
const data = e.dataTransfer.getData("text/plain");
handleDrop(data);
});
// Lifecycle: dragstart → dragover (target) → drop → dragend
Window and document events
window.addEventListener("load", handler); // page fully loaded (incl. images)
window.addEventListener("DOMContentLoaded", handler); // DOM ready (no images yet)
window.addEventListener("beforeunload", (e) => {
if (hasUnsavedChanges) {
e.preventDefault(); // browser shows confirmation
}
});
window.addEventListener("unload", handler); // legacy — pagehide preferred
window.addEventListener("pagehide", handler);
window.addEventListener("pageshow", handler);
window.addEventListener("resize", handler);
window.addEventListener("orientationchange", handler);
window.addEventListener("hashchange", handler);
window.addEventListener("popstate", (e) => {
// back/forward
});
window.addEventListener("online", handler);
window.addEventListener("offline", handler);
window.addEventListener("storage", handler); // localStorage from another tab
Visibility
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
pauseTimer();
} else {
resumeTimer();
}
});
The conventional discipline uses visibilitychange over legacy focus/blur for tab-switch detection.
Event delegation
Single listener on a common ancestor — substantial efficient for substantial dynamically-added elements:
const list = document.querySelector("#todos");
list.addEventListener("click", (e) => {
const item = e.target.closest(".todo");
if (!item) return;
if (e.target.matches(".delete-btn")) {
deleteTodo(item.dataset.id);
} else if (e.target.matches(".toggle-btn")) {
toggleTodo(item.dataset.id);
}
});
// Now adding new todos doesn't require new listeners:
list.appendChild(newTodoElement);
The pattern admits substantial benefits:
- One listener over many.
- Works for dynamically-added elements automatically.
- Substantial less memory.
- Substantial fewer references for garbage collection.
Custom events
const event = new CustomEvent("user-login", {
detail: { userId: 42, name: "Alice" },
bubbles: true,
cancelable: true
});
document.dispatchEvent(event);
// Listen:
document.addEventListener("user-login", (e) => {
console.log(e.detail.userId);
});
// Inside an element:
el.dispatchEvent(new CustomEvent("custom-thing", {
detail: { value: 42 },
bubbles: true
}));
The mechanism admits substantial custom messaging between substantial unrelated parts of code.
EventTarget
Any object can become an event source by extending EventTarget:
class Counter extends EventTarget {
#count = 0;
increment() {
this.#count++;
this.dispatchEvent(new CustomEvent("change", { detail: this.#count }));
}
get count() { return this.#count; }
}
const counter = new Counter();
counter.addEventListener("change", (e) => {
console.log("count is now", e.detail);
});
counter.increment(); // logs "count is now 1"
Common patterns
Throttled scroll handler
function throttle(fn, ms) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= ms) {
last = now;
fn.apply(this, args);
}
};
}
window.addEventListener("scroll", throttle(() => {
console.log(window.scrollY);
}, 100), { passive: true });
Debounced input handler
function debounce(fn, ms) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
};
}
input.addEventListener("input", debounce((e) => {
search(e.target.value);
}, 300));
Form submit with validation
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const data = Object.fromEntries(new FormData(form));
try {
await submitToServer(data);
form.reset();
showToast("Saved!");
} catch (err) {
showToast(`Error: ${err.message}`);
}
});
Keyboard shortcuts
document.addEventListener("keydown", (e) => {
// Ctrl+K (or Cmd+K on Mac):
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
e.preventDefault();
openSearch();
}
// Escape:
if (e.key === "Escape") {
closeModal();
}
// Single key (skip if in input):
if (e.target.matches("input, textarea")) return;
if (e.key === "/") {
e.preventDefault();
focusSearch();
}
});
Outside click
function onOutsideClick(el, callback) {
const handler = (e) => {
if (!el.contains(e.target)) {
callback(e);
}
};
document.addEventListener("click", handler);
return () => document.removeEventListener("click", handler);
}
const off = onOutsideClick(menuEl, () => closeMenu());
// Later:
off();
Click outside with AbortController
const controller = new AbortController();
document.addEventListener("click", (e) => {
if (!menuEl.contains(e.target)) closeMenu();
}, { signal: controller.signal });
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeMenu();
}, { signal: controller.signal });
function closeMenu() {
controller.abort();
menuEl.hidden = true;
}
Enter to submit
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
});
Drag-to-reorder
list.addEventListener("dragstart", (e) => {
e.dataTransfer.setData("text/plain", e.target.dataset.id);
e.target.classList.add("dragging");
});
list.addEventListener("dragend", (e) => {
e.target.classList.remove("dragging");
});
list.addEventListener("dragover", (e) => {
e.preventDefault();
const dragging = list.querySelector(".dragging");
const afterEl = getDragAfterElement(list, e.clientY);
if (afterEl) {
list.insertBefore(dragging, afterEl);
} else {
list.appendChild(dragging);
}
});
function getDragAfterElement(container, y) {
const els = [...container.querySelectorAll(".item:not(.dragging)")];
return els.reduce((closest, el) => {
const box = el.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset, element: el };
}
return closest;
}, { offset: -Infinity }).element;
}
Detect long press
let timer;
el.addEventListener("pointerdown", (e) => {
timer = setTimeout(() => {
// long press
showContextMenu(e);
}, 500);
});
el.addEventListener("pointerup", () => clearTimeout(timer));
el.addEventListener("pointerleave", () => clearTimeout(timer));
Copy to clipboard
button.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(text);
showToast("Copied!");
} catch (err) {
showToast("Copy failed");
}
});
Once-only handler
button.addEventListener("click", handler, { once: true });
// Or:
function once(target, type, handler) {
target.addEventListener(type, function fn(e) {
target.removeEventListener(type, fn);
handler(e);
});
}
Promise from event
function once(target, type) {
return new Promise(resolve => {
target.addEventListener(type, resolve, { once: true });
});
}
await once(button, "click");
console.log("clicked");
// With AbortSignal:
function once(target, type, signal) {
return new Promise((resolve, reject) => {
target.addEventListener(type, resolve, { once: true, signal });
signal?.addEventListener("abort", () => reject(signal.reason), { once: true });
});
}
Window events with cleanup
class Component {
#controller;
mount() {
this.#controller = new AbortController();
window.addEventListener("resize", this.#onResize, { signal: this.#controller.signal });
window.addEventListener("scroll", this.#onScroll, { signal: this.#controller.signal, passive: true });
}
unmount() {
this.#controller.abort();
}
#onResize = () => { /* ... */ };
#onScroll = () => { /* ... */ };
}
A note on default actions
Many events have default actions:
| Event | Default |
|---|---|
submit | Submit the form |
click on <a> | Navigate |
click on <input type="checkbox"> | Toggle |
keydown Tab | Move focus |
contextmenu | Show menu |
dragover | None (preventDefault required for drop) |
Use e.preventDefault() to cancel — but only when needed. Cancelling click on a link admits substantial accessibility issues (keyboard navigation, middle-click new tab).
A note on the conventional discipline
The contemporary events advice:
- Use
addEventListener— neverel.onclick = ...(admits one handler only). - Use event delegation for substantial dynamic lists.
- Use
AbortControllerfor substantial cleanup. - Use
{ passive: true }on scroll/wheel/touch handlers. - Use
pointer*events over mouse/touch where possible. - Use
Custom Eventwithdetailfor inter-component messaging. - Use
closestwith delegation. - Avoid
keypress— usekeydown. - Mark forms with
novalidateif implementing custom validation. - Always
preventDefaultfor AJAX form submission. - Use throttle/debounce for high-frequency events.
The combination — substantial event surface, three-phase propagation, the conventional bubble-phase discipline, the substantial delegation pattern, the substantial cleanup mechanism via AbortController — is the substance of DOM event work. The discipline produces code with substantial responsiveness and substantial cleanup.