JavaScript Events

Respond to user actions with addEventListener, understand bubbling and capturing, and master event delegation for dynamic UIs.

Beginner 11 min read 11 examples

Event Basics

addEventListener(type, handler) is the standard way to attach event listeners. It can be called multiple times to attach multiple handlers for the same event.

JavaScript
const btn = document.querySelector('#myBtn');

// Basic listener - inline anonymous function (cannot be removed)
btn.addEventListener('click', () => {
    console.log('Button clicked');
});

// Named function - can be removed later
function handleClick(event) {
    console.log('Clicked:', event.target.textContent);
}
btn.addEventListener('click', handleClick);

// Multiple listeners on the same event - both fire
btn.addEventListener('click', () => console.log('Listener 1'));
btn.addEventListener('click', () => console.log('Listener 2'));

// Remove a listener - must pass the same function reference
btn.removeEventListener('click', handleClick);

// Common event types
window.addEventListener('load',    () => console.log('Page fully loaded'));
window.addEventListener('resize',  () => console.log('Window resized'));
document.addEventListener('keydown', e => console.log('Key:', e.key));
document.addEventListener('DOMContentLoaded', () => {
    // HTML parsed and DOM ready, but images/styles may still be loading
    console.log('DOM ready');
});

The Event Object

Every event handler receives an Event object with details about what happened. Different event types add extra properties.

JavaScript
// Common Event properties available on all events
document.addEventListener('click', function(event) {
    console.log(event.type);          // 'click'
    console.log(event.target);        // element that was clicked
    console.log(event.currentTarget); // element the listener is on
    console.log(event.timeStamp);     // ms since page load
    console.log(event.isTrusted);     // true=user action, false=dispatched by code
});

// MouseEvent - extends Event
document.addEventListener('click', function(e) {
    console.log(e.clientX, e.clientY); // position relative to viewport
    console.log(e.pageX, e.pageY);     // position relative to document
    console.log(e.button);             // 0=left, 1=middle, 2=right
    console.log(e.ctrlKey, e.shiftKey, e.altKey, e.metaKey); // modifier keys
});

// KeyboardEvent - extends Event
document.addEventListener('keydown', function(e) {
    console.log(e.key);      // 'a', 'Enter', 'ArrowUp', etc.
    console.log(e.code);     // 'KeyA', 'Enter', 'ArrowUp' (physical key)
    console.log(e.repeat);   // true if key is held down
    if (e.key === 'Enter' && e.ctrlKey) {
        console.log('Ctrl+Enter pressed');
    }
});

// InputEvent
document.querySelector('input').addEventListener('input', function(e) {
    console.log(e.target.value);   // current value after change
    console.log(e.data);           // character(s) just typed (null for delete)
    console.log(e.inputType);      // 'insertText', 'deleteContentBackward', etc.
});

Bubbling and Capturing

Events travel in three phases: capture (down the tree), target (at the element), then bubble (back up). By default, listeners use the bubble phase.

JavaScript
// <div id="outer">
//   <div id="inner">
//     <button id="btn">Click me</button>
//   </div>
// </div>

document.querySelector('#outer').addEventListener('click', () => console.log('outer'));
document.querySelector('#inner').addEventListener('click', () => console.log('inner'));
document.querySelector('#btn').addEventListener('click',   () => console.log('btn'));

// Clicking the button logs: btn -> inner -> outer (bubble order)
// The event fires at the target (#btn) then bubbles UP the tree

// Capture phase - fires top-down before the target
// Pass true or { capture: true } as third argument
document.querySelector('#outer').addEventListener('click', () => {
    console.log('outer CAPTURE');
}, true);
// With capture, clicking button logs: outer CAPTURE -> btn -> inner -> outer

// Most events bubble: click, keydown, input, submit, change
// Events that do NOT bubble: focus, blur, load, scroll (on window is special)
// Focusable workaround: use 'focusin'/'focusout' which DO bubble
document.addEventListener('focusin',  e => console.log('focused:', e.target));
document.addEventListener('focusout', e => console.log('blurred:', e.target));

stopPropagation and preventDefault

JavaScript
// preventDefault() - cancels the browser's default action for the event
document.querySelector('a.internal').addEventListener('click', function(e) {
    e.preventDefault(); // stops navigation
    // handle click in JS instead (e.g., client-side routing)
});

document.querySelector('form').addEventListener('submit', function(e) {
    e.preventDefault(); // stops page reload / form POST
    const data = new FormData(e.target);
    fetch('/api/submit', { method: 'POST', body: data });
});

document.addEventListener('contextmenu', e => {
    e.preventDefault(); // disable right-click menu (use sparingly!)
});

// stopPropagation() - stops the event from continuing up (or down) the tree
document.querySelector('.dropdown-menu').addEventListener('click', function(e) {
    e.stopPropagation(); // prevent clicks inside menu from closing it
});
document.addEventListener('click', () => closeAllDropdowns());

// stopImmediatePropagation() - stops propagation AND prevents other listeners
// on the SAME element from firing
btn.addEventListener('click', function(e) {
    e.stopImmediatePropagation();
});
btn.addEventListener('click', () => {
    // this will NOT fire - stopImmediatePropagation blocked it
});

// Note: do NOT use stopPropagation() casually - it breaks event delegation
// and makes debugging harder. Use it only when truly necessary.
Avoid overusing stopPropagation()

Stopping propagation prevents parent listeners from receiving the event - including analytics trackers, accessibility tools, and your own event delegation. Prefer preventDefault() to cancel browser behavior, and use stopPropagation() only for specific, intentional cases like closing dropdowns.

Event Delegation

Instead of adding listeners to every child, attach one listener on the parent and use event.target to detect which child was interacted with. This works for dynamically added elements too.

JavaScript
// BAD: listener on every item (breaks for dynamically added items)
document.querySelectorAll('.item').forEach(item => {
    item.addEventListener('click', handleItemClick);
});

// GOOD: one listener on the parent, works for all current and future items
const list = document.querySelector('.item-list');
list.addEventListener('click', function(e) {
    const item = e.target.closest('.item'); // walk up to nearest .item
    if (!item || !list.contains(item)) return; // click outside items
    handleItemClick(item);
});

// Delegation with data attributes - cleaner than class checks
const toolbar = document.querySelector('.toolbar');
toolbar.addEventListener('click', function(e) {
    const btn = e.target.closest('[data-action]');
    if (!btn) return;
    const action = btn.dataset.action;
    switch (action) {
        case 'bold':   applyBold();   break;
        case 'italic': applyItalic(); break;
        case 'save':   saveDocument(); break;
    }
});

// Multiple event types with one delegated listener
document.querySelector('.card-list').addEventListener('click', function(e) {
    if (e.target.matches('.btn-delete')) {
        e.target.closest('.card').remove();
    }
    if (e.target.matches('.btn-edit')) {
        openEditor(e.target.closest('.card'));
    }
});

once, passive, and signal

addEventListener accepts an options object as its third argument with powerful listener modifiers.

JavaScript
// once: true - listener fires once, then auto-removes itself
btn.addEventListener('click', initializeApp, { once: true });

document.querySelector('.modal').addEventListener('transitionend', function() {
    this.remove(); // works without needing removeEventListener
}, { once: true });

// passive: true - tells the browser the handler will NOT call preventDefault()
// Browser can then start scrolling immediately without waiting for your handler
// Significant scroll performance improvement on mobile
window.addEventListener('touchstart', handleTouchStart, { passive: true });
window.addEventListener('scroll',     trackScroll,       { passive: true });

// signal: AbortController - remove listener by aborting the controller
const controller = new AbortController();

document.addEventListener('keydown', handleKeyDown, {
    signal: controller.signal
});
document.addEventListener('keyup', handleKeyUp, {
    signal: controller.signal
});
document.addEventListener('click', handleClick, {
    signal: controller.signal
});

// Remove ALL listeners registered with this controller at once
controller.abort();

// Practical: clean up listeners when a component unmounts
class Modal {
    constructor(el) {
        this.el = el;
        this.controller = new AbortController();
        const opts = { signal: this.controller.signal };
        el.addEventListener('click', this.handleClick.bind(this), opts);
        document.addEventListener('keydown', this.handleKey.bind(this), opts);
    }
    destroy() {
        this.controller.abort(); // cleans up all listeners at once
    }
}

Custom Events

JavaScript
// Create a custom event
const event = new CustomEvent('user:login', {
    bubbles: true,    // should it bubble up the DOM?
    cancelable: true, // can listeners call preventDefault()?
    detail: {         // arbitrary data payload
        userId: 42,
        username: 'alice'
    }
});

// Dispatch on an element (bubbles up from there)
document.querySelector('#app').dispatchEvent(event);

// Listen for it anywhere up the tree
document.addEventListener('user:login', function(e) {
    console.log('User logged in:', e.detail.username); // 'alice'
});

// Practical: component communication without tight coupling
function notifyCartUpdate(item, quantity) {
    document.dispatchEvent(new CustomEvent('cart:updated', {
        bubbles: true,
        detail: { item, quantity, total: getCartTotal() }
    }));
}

// Cart badge component listens independently
document.addEventListener('cart:updated', function(e) {
    document.querySelector('.cart-count').textContent = e.detail.total;
});

// Check if default was prevented
const canProceed = document.dispatchEvent(new CustomEvent('form:validate', {
    bubbles: true,
    cancelable: true
}));
if (!canProceed) return; // a listener called preventDefault()

Common Event Types

CategoryEventWhen it firesBubbles?
MouseclickLeft button pressed and releasedYes
dblclickDouble-clickedYes
mouseenterPointer enters element (no bubble)No
mouseoverPointer enters element or childYes
KeyboardkeydownKey pressed (repeats while held)Yes
keyupKey releasedYes
keypressDeprecated - use keydownYes
ForminputValue changed (every keystroke)Yes
changeValue committed (on blur for text)Yes
submitForm submittedYes
focus / blurElement gains/loses focusNo
WindowDOMContentLoadedHTML parsed, DOM readyNo
loadPage fully loaded (images too)No
resize / scrollViewport resized / document scrolledNo / No

Frequently Asked Questions

addEventListener lets you attach multiple handlers for the same event, supports options like once and passive, and allows removal with removeEventListener. element.onclick = fn only allows one handler (assigning again replaces the previous one) and cannot use those options. Always prefer addEventListener.

Event delegation attaches a single listener on a parent element instead of individual listeners on each child. Because events bubble up, the parent catches events from all children. Benefits: works for dynamically added elements, uses less memory (one listener vs hundreds), and simplifies code. Use event.target and closest() to identify which child was clicked.

Call element.removeEventListener(type, handler) with the same function reference used in addEventListener. Anonymous functions cannot be removed. Store the handler in a variable or use the { once: true } option for one-time events. You can also use an AbortController and pass its signal in the options - calling controller.abort() removes all listeners registered with that signal.

event.target is the element that originally triggered the event (e.g., the button that was clicked). event.currentTarget is the element the event listener is attached to (which may be a parent due to bubbling). Inside a listener, this also refers to currentTarget - unless the handler is an arrow function.