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 phasescapture (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.