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

type

The event type string ("click", "keydown", …​).

target

The object the event actually occurred on — fixed for the lifetime of the event as it propagates.

currentTarget

The object the currently executing handler was registered on. During capturing or bubbling, this changes at each step even though target stays the same — the two are only guaranteed equal for a handler registered directly on the target itself.

timeStamp

A relative (not absolute) millisecond timestamp, useful for measuring elapsed time between two events.

isTrusted

true if the browser itself dispatched the event (a real click), false if it was created and dispatched by script (e.g. via element.dispatchEvent(new CustomEvent(…​))).

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:

  1. Capturing — starting from Window, the event travels down the ancestor chain toward the target, invoking any handler registered with capture: true (or addEventListener(type, fn, true)) at each ancestor along the way. Capturing handlers registered on the target itself are not invoked during this phase.

  2. Target — handlers registered directly on the target element run, in registration order, regardless of whether they were registered as capturing or not.

  3. Bubbling — the event travels back up the same ancestor chain, from the target’s parent to Document and then to Window, 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:

sequenceDiagram participant Win as Window participant Doc as Document participant Outer as div#outer participant Mid as div#middle participant Btn as button#inner (target) Note over Win,Btn: Phase 1 -- Capturing (Window down to the target's parent) Win->>Doc: capturing handlers, if any Doc->>Outer: capturing handlers, if any Outer->>Mid: capturing handlers, if any Note over Btn: Phase 2 -- Target Mid->>Btn: target's own handlers run, in registration order Note over Btn,Win: Phase 3 -- Bubbling (target's parent back up to Window) Btn->>Mid: non-capturing handlers, if any Mid->>Outer: non-capturing handlers, if any Outer->>Doc: non-capturing handlers, if any Doc->>Win: non-capturing handlers, if any

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() — everything stopPropagation() 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 the passive: true option 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).

  • currentTarget vs. target — inside a delegated handler, event.currentTarget is always the ancestor the listener was registered on, while event.target is whatever the user actually interacted with, so event.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 this binding, referenced above when contrasting function and arrow-function handlers.