Events
|
This section documents modern ECMAScript and core browser JavaScript APIs — it is not tied to any specific framework or library (React, Vue, Angular, etc.). This content was generated with the assistance of AI and should be verified against the current ECMAScript specification and MDN documentation before relying on it in production, since JavaScript language features and browser API support continue to evolve. This section’s bibliography lists the reference material consulted while preparing these pages. |
Client-side JavaScript is fundamentally event-driven: rather than running top to bottom and finishing, a page’s script mostly sits idle and reacts to things that happen to the document, the browser, or a specific element — a click, a keystroke, the network finishing a request. This page covers the event vocabulary and machinery that sits underneath all of that: how to register and remove handlers, what the event object passed to a handler contains, how an event travels through the document tree (capturing, target, and bubbling), how to stop or redirect that travel, and event delegation — the pattern of registering one listener on a shared ancestor instead of one per element, which this page treats as a first-class technique rather than a footnote. Selecting the elements you attach listeners to is covered separately in DOM Basics.
A handful of terms recur throughout this page: the event type is a string naming what happened ("click",
"keydown"); the event target is the object it occurred on (a Window, Document, or Element, most
commonly); an event handler (or listener — the two terms are used interchangeably here, as in most everyday
usage) is the function invoked in response; and the event object passed to that function describes the event
in detail (see The Event Object below).
Registering Event Handlers
There are two fundamentally different ways to register a handler: setting a property on the target (or an
attribute in HTML), or calling addEventListener().
Handler Properties (on*)
The oldest and simplest technique is to assign a function to a property whose name is "on" followed by the
lowercase event type — onclick, onchange, onload, and so on:
window.onload = function () {
let form = document.querySelector("form#shipping");
form.onsubmit = function (event) {
if (!isFormValid(this)) { // `this` is the form -- the property is like a method
event.preventDefault(); // cancel submission if validation fails
}
};
};
A close cousin is defining the handler as an HTML attribute, whose value is the body of the handler function (not a full function declaration):
<button onclick="console.log('Thank you');">Please Click</button>
This runs the string of code inside a with-scoped wrapper that exposes the target element, its containing
<form>, and document as if they were in-scope variables — a source of confusing bugs, and one of several
reasons inline HTML handler attributes are considered outdated practice in modern code.
Both forms share the same limitation: a target can only have one handler per event type stored this way.
Assigning a second function to onclick silently replaces the first — there is no way to register two
independent onclick handlers on the same element.
addEventListener() and removeEventListener()
Every event target — Window, Document, and every Element — defines addEventListener(type, handler,
options?). Unlike a property assignment, calling it does not overwrite any previously registered handler:
let b = document.querySelector("#mybutton");
b.onclick = () => console.log("Thanks for clicking me!");
b.addEventListener("click", () => console.log("Thanks again!"));
// a click now logs BOTH messages, in registration order
Handlers registered for the same type on the same target — whether via addEventListener(), a property, or a
mix of both — run in the order they were registered. Calling addEventListener() again with the exact same
type, function reference, and options is a harmless no-op: the handler stays registered exactly once.
removeEventListener(type, handler, options?) is the mirror image, and it only removes a handler if its type,
function reference, and capture option all match what was passed to addEventListener():
// Register temporary handlers on mousedown, then tear them down on mouseup --
// a common pattern for implementing drag interactions.
document.addEventListener("mousedown", () => {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
});
function handleMouseUp() {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
}
An anonymous function (an inline arrow function or closure) can never be removed, because removeEventListener()
needs the same function reference that was originally registered — keep a named reference to any handler you
may need to detach later.
Why addEventListener() Is Generally Preferred
Three things make addEventListener() the default choice over on* properties in modern code: multiple
listeners — a property assignment holds only one handler per event type, while addEventListener() supports
any number, all invoked in registration order, without one clobbering another; non-destructive registration — library code, framework internals, and application code can all attach their own handlers to the same element
without coordinating or checking whether one is "already there"; and the options argument — a third parameter
with no property-assignment equivalent at all, unlocking capture, once, and passive, covered next.
Listener Options: capture, once, passive
The optional third argument to addEventListener() can be a boolean (historically, and still, meaning
"register as a capturing handler" — see
Event Propagation: Capturing, Target, and Bubbling) or an options object with three
recognized properties:
document.addEventListener("click", handleClick, {
capture: true, // invoke during the capturing phase, not the bubbling phase
once: true, // automatically remove this listener after it fires once
passive: true, // promise that the handler never calls preventDefault()
});
capture controls when in the propagation sequence the handler fires (details below). once removes the
listener automatically after its first invocation — useful for a handler that should only ever run a single
time, without a matching removeEventListener() call anywhere. passive is a performance hint: it tells the
browser this handler will never call preventDefault(), so the browser is free to start its default action
(most importantly, scrolling in response to "touchmove"/"wheel" events) immediately instead of waiting to
see whether the handler cancels it. Chrome and Firefox default "touchmove" and "mousewheel" listeners to
passive already, precisely because blocking smooth scrolling on handler execution is so costly to perceived
performance — pass passive: false explicitly if such a handler genuinely needs to cancel the gesture.
removeEventListener() also accepts an options object, but only its capture property matters there; once
and passive are meaningless when removing a listener and are ignored.
The Event Object
Every handler is invoked with a single Event object argument describing what happened. The properties common
to every event type are:
| Property | Meaning |
|---|---|
|
The event type string ( |
|
The object the event actually occurred on — fixed for the lifetime of the event as it propagates. |
|
The object the currently executing handler was registered on. During capturing or bubbling, this changes at
each step even though |
|
A relative (not absolute) millisecond timestamp, useful for measuring elapsed time between two events. |
|
|
Specific event types add their own properties on top of these — clientX/clientY on mouse and pointer
events, key/code on keyboard events, and so on.
Handlers are invoked with this set to the target the handler was registered on — the same as if the handler
were a method of that object — even when registered via addEventListener(). This does not apply to arrow
function handlers, since an arrow function always keeps the this of the scope it was defined in rather than
taking one from how it is called:
button.addEventListener("click", function () {
console.log(this === button); // true -- a regular function gets `this` from the call
});
button.addEventListener("click", () => {
console.log(this); // whatever `this` was in the surrounding scope, NOT the button
});
Modern handlers should not return anything; a return false that cancels the default action is a legacy
convention from on* properties. Use event.preventDefault() instead (see below).
Event Propagation: Capturing, Target, and Bubbling
When an event’s target is a standalone object like Window, propagation is trivial — the browser just invokes
that object’s own handlers. But when the target is a Document or an Element nested inside others, the
browser dispatches the event in three ordered phases:
-
Capturing — starting from
Window, the event travels down the ancestor chain toward the target, invoking any handler registered withcapture: true(oraddEventListener(type, fn, true)) at each ancestor along the way. Capturing handlers registered on the target itself are not invoked during this phase. -
Target — handlers registered directly on the target element run, in registration order, regardless of whether they were registered as capturing or not.
-
Bubbling — the event travels back up the same ancestor chain, from the target’s parent to
Documentand then toWindow, invoking any non-capturing handler registered along the way.
Most events bubble; the notable exceptions are "focus", "blur", "scroll", and the "load"/"error" events
fired by resource elements (<img>, <script>, <link>, and others) — none of these bubble, so observing one
from an ancestor requires either a capturing listener (capture: true, above) or attaching the handler directly
to the element itself. Window’s own `"load" event (the whole page has finished loading) is a separate,
unrelated event that also doesn’t bubble, since Window has no ancestor to bubble to.
For a click on a deeply nested button, the full sequence looks like this:
Capturing exists mainly so an ancestor can peek at (or, combined with stopPropagation(), filter out) an event
before it reaches its target — handling mouse-drag gestures, where motion must be tracked by the element
being dragged rather than whatever the pointer happens to be over, is a classic use case. Bubbling is what makes
Event Delegation possible.
Stopping Propagation and Preventing Default Actions
Three methods on the event object let a handler change what happens next, and it is easy to confuse them:
-
event.stopPropagation()— stops the event from continuing to capture or bubble any further. Other handlers registered on the same object still run; handlers on any other object in the propagation chain do not. -
event.stopImmediatePropagation()— everythingstopPropagation()does, plus it also prevents any other handler registered on the same object from running, even ones registered before it. -
event.preventDefault()— cancels the browser’s default action for this event (following a clicked link, submitting a form, entering typed text, scrolling on touch) without affecting propagation at all. A handler registered with thepassive: trueoption cannot call this effectively — the browser has already committed to its default action by the time such a handler runs.
form.addEventListener("submit", (event) => {
if (!isFormValid(form)) {
event.preventDefault(); // stop the browser from submitting...
// ...but propagation continues; other "submit" handlers still run
}
});
list.addEventListener("click", (event) => {
event.stopPropagation(); // no ancestor's "click" handler will run
});
These three are independent: calling preventDefault() does not stop propagation, and calling
stopPropagation() does not cancel the default action — reach for the specific method the situation calls
for, and combine them explicitly when both effects are needed.
Event Delegation
Registering a separate listener on every element you care about is straightforward to write but expensive to
scale: for N interactive rows in a table, a naive approach attaches N listener closures, each consuming
memory and each needing to be individually torn down (and re-attached) whenever rows are added, removed, or
re-rendered.
// One listener PER row -- N closures alive at once, and every row added or
// removed later must remember to attach/detach its own listener too.
document.querySelectorAll("ul#todo-list li button.delete").forEach((button) => {
button.addEventListener("click", (event) => {
event.target.closest("li").remove();
});
});
Event delegation uses bubbling to replace all of that with a single listener registered on a shared ancestor.
Because a click on any descendant bubbles up through that ancestor, one handler there can inspect event.target
(the actual element clicked) to decide what to do — regardless of how many descendants exist or when they were
added:
// One listener total, registered once, on the list itself. Works for rows
// that don't exist yet at registration time, and never needs cleanup per row.
document.querySelector("ul#todo-list").addEventListener("click", (event) => {
let button = event.target.closest("button.delete");
if (button) {
button.closest("li").remove();
}
});
The trade-offs run in delegation’s favor for most dynamic UI:
-
Memory — one closure and one entry in the browser’s internal listener table instead of
N, which matters directly for long lists, virtualized/infinite-scroll UIs, and any DOM that gets rebuilt frequently. -
Dynamically added elements — new
<li>elements inserted later are covered automatically, since the listener lives on the ancestor, not on each row. A per-element approach requires remembering to attach a new listener every time a new element appears (and to remove it when the element is removed, to avoid leaking). -
currentTargetvs.target— inside a delegated handler,event.currentTargetis always the ancestor the listener was registered on, whileevent.targetis whatever the user actually interacted with, soevent.target.closest(selector)(see DOM Basics) is the standard way to work out which descendant — if any — the click should be attributed to.
Delegation is not free: the handler runs on every bubbling event within the ancestor’s subtree, even ones it
ends up ignoring, and events that do not bubble ("focus", "blur") cannot be delegated at all without
switching to their bubbling equivalents ("focusin", "focusout"). For a handful of static, never-changing
elements, one listener per element is perfectly reasonable; delegation earns its complexity once the element
count is large, unbounded, or dynamic.
See Also
-
DOM Basics — selecting the elements you attach listeners to (
querySelector(),querySelectorAll(),closest()) and traversing the document tree. -
Asynchronous JavaScript — how event-driven code relates to Promises, the microtask/macrotask queues, and
async/await. -
Functions, Expressions & Operators — arrow function
thisbinding, referenced above when contrastingfunctionand arrow-function handlers.