Polyglot
› Languages › Web (HTML / CSS / JS) › events
Web (HTML / CSS / JS) § events

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:

  1. Capture phase — from window down to target.
  2. Target phase — at the element.
  3. 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:

EventDefault
submitSubmit the form
click on <a>Navigate
click on <input type="checkbox">Toggle
keydown TabMove focus
contextmenuShow menu
dragoverNone (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 — never el.onclick = ... (admits one handler only).
  • Use event delegation for substantial dynamic lists.
  • Use AbortController for substantial cleanup.
  • Use { passive: true } on scroll/wheel/touch handlers.
  • Use pointer* events over mouse/touch where possible.
  • Use Custom Event with detail for inter-component messaging.
  • Use closest with delegation.
  • Avoid keypress — use keydown.
  • Mark forms with novalidate if implementing custom validation.
  • Always preventDefault for 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.