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.
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.
// 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.
// <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
// 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.
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.
// 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.
// 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
// 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
| Category | Event | When it fires | Bubbles? |
|---|---|---|---|
| Mouse | click | Left button pressed and released | Yes |
dblclick | Double-clicked | Yes | |
mouseenter | Pointer enters element (no bubble) | No | |
mouseover | Pointer enters element or child | Yes | |
| Keyboard | keydown | Key pressed (repeats while held) | Yes |
keyup | Key released | Yes | |
keypress | Deprecated - use keydown | Yes | |
| Form | input | Value changed (every keystroke) | Yes |
change | Value committed (on blur for text) | Yes | |
submit | Form submitted | Yes | |
focus / blur | Element gains/loses focus | No | |
| Window | DOMContentLoaded | HTML parsed, DOM ready | No |
load | Page fully loaded (images too) | No | |
resize / scroll | Viewport resized / document scrolled | No / No |