Event System
JavaScript's event system is the core mechanism for handling user interactions and browser events. Understanding bubbling, capturing, and event delegation enables efficient event handling.
Event Listener Basics
// Basic form of addEventListener
element.addEventListener(eventType, handler, options);
const button = document.querySelector('#btn');
// Basic usage
button.addEventListener('click', function(event) {
console.log('Clicked', event.target);
});
// Arrow function
button.addEventListener('click', (event) => {
console.log(event.type); // 'click'
console.log(event.target); // the element where the event occurred
console.log(event.currentTarget); // the element where the handler is registered
console.log(event.timeStamp); // time of occurrence (ms)
});
// Removing an event listener (requires the same function reference)
function handleClick(e) {
console.log('Click');
}
button.addEventListener('click', handleClick);
button.removeEventListener('click', handleClick); // must be the exact same function reference
// removeEventListener cannot remove arrow functions
// (a new function is created every time)
addEventListener Options
once Option: Run Only Once
// once: automatically removes the listener after the event fires once
button.addEventListener('click', (e) => {
console.log('Runs only once');
}, { once: true });
// Real-world: initialization logic
document.addEventListener('DOMContentLoaded', initializeApp, { once: true });
// Real-world: remove class after animation ends
element.addEventListener('animationend', (e) => {
e.target.classList.remove('animating');
}, { once: true });
passive Option: Scroll Performance Optimization
// Setting passive: true tells the browser that preventDefault() won't be called
// Allows the browser to process scrolling without delay → performance improvement
// Improve scroll performance on mobile
document.addEventListener('touchstart', handler, { passive: true });
document.addEventListener('touchmove', handler, { passive: true });
window.addEventListener('scroll', onScroll, { passive: true });
// Calling preventDefault() when passive: true triggers a warning
document.addEventListener('touchmove', (e) => {
e.preventDefault(); // Warning! Ignored when registered with passive: true
}, { passive: false }); // Use passive: false when default behavior prevention is needed
// General recommendations:
// - scroll events: passive: true (no need to prevent default behavior)
// - sliders, drag: passive: false (need to prevent default scrolling)
capture Option: Handle During Capturing Phase
// capture: true → executes during the capturing phase
// capture: false (default) → executes during the bubbling phase
document.addEventListener('click', (e) => {
console.log('Capturing: document');
}, { capture: true });
document.body.addEventListener('click', (e) => {
console.log('Capturing: body');
}, true); // can also be passed as the third argument
button.addEventListener('click', (e) => {
console.log('Bubbling: button');
});
// Output order when clicked:
// Capturing: document
// Capturing: body
// Bubbling: button
Event Bubbling and Capturing
After an event occurs, it propagates along the DOM tree.
Propagation phases:
1. Capturing: document → element where event occurred
2. Target: the element where the event occurred
3. Bubbling: element where event occurred → document
// Event bubbling example
// HTML: <div id="outer"><div id="inner"><button>Click</button></div></div>
document.querySelector('#outer').addEventListener('click', (e) => {
console.log('outer clicked');
});
document.querySelector('#inner').addEventListener('click', (e) => {
console.log('inner clicked');
});
document.querySelector('button').addEventListener('click', (e) => {
console.log('button clicked');
console.log('target:', e.target); // button (actual clicked element)
console.log('currentTarget:', e.currentTarget); // button (handler-registered element)
});
// Output when button is clicked:
// button clicked (target phase)
// inner clicked (bubbling)
// outer clicked (bubbling)
stopPropagation vs stopImmediatePropagation
const button = document.querySelector('button');
// stopPropagation: stops event propagation (bubbling/capturing)
button.addEventListener('click', (e) => {
e.stopPropagation();
console.log('No longer propagates to parent');
});
// But other handlers on the same element still execute
button.addEventListener('click', (e) => {
console.log('This handler still executes');
});
// stopImmediatePropagation: stops propagation + stops remaining handlers on same element
button.addEventListener('click', (e) => {
e.stopImmediatePropagation();
console.log('First handler');
});
button.addEventListener('click', (e) => {
console.log('This handler does not execute!');
});
// stopPropagation: only prevents propagation to parent
// stopImmediatePropagation: prevents parent propagation + remaining handlers on same element
preventDefault
// Prevent default behavior
const link = document.querySelector('a');
link.addEventListener('click', (e) => {
e.preventDefault(); // prevent link navigation
console.log('Link clicked, navigation prevented');
});
// Prevent form submission
const form = document.querySelector('form');
form.addEventListener('submit', (e) => {
e.preventDefault(); // prevent default form submission
const formData = new FormData(form);
submitFormAsync(Object.fromEntries(formData));
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 's') {
e.preventDefault(); // prevent browser save
saveDocument();
}
});
// Custom right-click context menu
document.addEventListener('contextmenu', (e) => {
e.preventDefault();
showCustomContextMenu(e.clientX, e.clientY);
});
Event Delegation Pattern
Event delegation is a pattern where you register an event listener on a parent element and use bubbling to handle events from child elements.
// Bad approach: register an event listener on each item
document.querySelectorAll('.item').forEach(item => {
item.addEventListener('click', handleItemClick); // N listeners
});
// Good approach: event delegation - register only one on the parent
const list = document.querySelector('#item-list');
list.addEventListener('click', (e) => {
const item = e.target.closest('.item'); // find the clicked .item
if (!item) return; // ignore if not .item
handleItemClick(item);
});
Handling Dynamic Elements
The greatest advantage of event delegation is that dynamically added elements are also handled automatically.
const list = document.querySelector('#todo-list');
// Handle dynamic items with event delegation
list.addEventListener('click', (e) => {
// Checkbox click
if (e.target.matches('input[type="checkbox"]')) {
const item = e.target.closest('.todo-item');
item.classList.toggle('completed', e.target.checked);
}
// Delete button click
if (e.target.matches('.delete-btn') || e.target.closest('.delete-btn')) {
const item = e.target.closest('.todo-item');
const id = item.dataset.id;
deleteTodo(id);
item.remove();
}
// Edit button click
if (e.target.matches('[data-action="edit"]')) {
const id = e.target.closest('[data-id]').dataset.id;
startEditing(id);
}
});
// Dynamically added items later also work
function addTodo(text) {
const li = document.createElement('li');
li.className = 'todo-item';
li.dataset.id = generateId();
li.innerHTML = `
<input type="checkbox">
<span>${text}</span>
<button class="delete-btn" data-action="delete">Delete</button>
<button data-action="edit">Edit</button>
`;
list.appendChild(li);
}
Precise Delegation Using closest
// Safe handling even in nested structures
document.querySelector('.product-grid').addEventListener('click', (e) => {
// Find the nearest .product-card from the clicked element
const card = e.target.closest('.product-card');
if (!card) return;
// Determine what action was performed
const action = e.target.closest('[data-action]')?.dataset.action;
switch (action) {
case 'add-to-cart': {
const productId = card.dataset.productId;
addToCart(productId);
showToast('Added to cart');
break;
}
case 'toggle-wishlist': {
const productId = card.dataset.productId;
toggleWishlist(productId);
e.target.closest('[data-action]').classList.toggle('active');
break;
}
default: {
// Card itself clicked → navigate to detail page
const productId = card.dataset.productId;
navigateToProduct(productId);
}
}
});
Publishing/Subscribing Custom Events with CustomEvent
Using CustomEvent reduces coupling between components.
// Create a custom event
const event = new CustomEvent('user-logged-in', {
detail: {
userId: 123,
username: 'Alice',
role: 'admin'
},
bubbles: true, // enable bubbling
cancelable: true // allows preventDefault
});
// Publish the event
document.dispatchEvent(event);
// Publish from a specific element
const loginButton = document.querySelector('#login-btn');
loginButton.dispatchEvent(new CustomEvent('login-attempt', {
detail: { username: 'Alice' },
bubbles: true
}));
// Subscribe
document.addEventListener('user-logged-in', (e) => {
const { userId, username, role } = e.detail;
console.log(`${username} has logged in (role: ${role})`);
updateNavigation(role);
});
// Real-world: event bus pattern
class EventBus {
#listeners = new Map();
on(eventName, handler) {
if (!this.#listeners.has(eventName)) {
this.#listeners.set(eventName, new Set());
}
this.#listeners.get(eventName).add(handler);
// Return an unsubscribe function
return () => this.off(eventName, handler);
}
off(eventName, handler) {
this.#listeners.get(eventName)?.delete(handler);
}
emit(eventName, data) {
this.#listeners.get(eventName)?.forEach(handler => handler(data));
}
once(eventName, handler) {
const unsubscribe = this.on(eventName, (data) => {
handler(data);
unsubscribe();
});
return unsubscribe;
}
}
const bus = new EventBus();
// Usage
const unsubscribe = bus.on('cart:updated', ({ items, total }) => {
updateCartUI(items, total);
});
bus.emit('cart:updated', { items: [...], total: 15000 });
// Unsubscribe when no longer needed
unsubscribe();
Keyboard Events
// keydown: when a key is pressed (repeats)
// keyup: when a key is released
// keypress: when a character key is entered (deprecated)
document.addEventListener('keydown', (e) => {
console.log(e.key); // 'a', 'Enter', 'ArrowUp', ' ', etc.
console.log(e.code); // 'KeyA', 'Enter', 'ArrowUp', 'Space' (physical key)
console.log(e.keyCode); // deprecated, do not use
console.log(e.ctrlKey); // whether Ctrl key is pressed
console.log(e.shiftKey); // whether Shift key is pressed
console.log(e.altKey); // whether Alt key is pressed
console.log(e.metaKey); // Mac Command / Windows key
console.log(e.repeat); // whether key is repeating
});
// Real-world: shortcut system
const shortcuts = new Map([
['ctrl+s', saveDocument],
['ctrl+z', undo],
['ctrl+shift+z', redo],
['Escape', closeModal],
['ctrl+/', toggleComment],
]);
document.addEventListener('keydown', (e) => {
const parts = [];
if (e.ctrlKey || e.metaKey) parts.push('ctrl');
if (e.shiftKey) parts.push('shift');
if (e.altKey) parts.push('alt');
parts.push(e.key.toLowerCase());
const shortcut = parts.join('+');
const action = shortcuts.get(shortcut);
if (action) {
e.preventDefault();
action(e);
}
});
// input event handling
const searchInput = document.querySelector('#search');
searchInput.addEventListener('input', (e) => {
const query = e.target.value.trim();
if (query.length >= 2) {
performSearch(query);
}
});
// Check if IME composition is in progress (e.g., Korean input)
searchInput.addEventListener('compositionstart', () => { isComposing = true; });
searchInput.addEventListener('compositionend', () => { isComposing = false; });
searchInput.addEventListener('input', (e) => {
if (!isComposing) {
handleSearch(e.target.value);
}
});
Mouse Events
const canvas = document.querySelector('canvas');
// Mouse event types
canvas.addEventListener('mousedown', (e) => {
console.log(e.button); // 0: left, 1: middle, 2: right
console.log(e.clientX, e.clientY); // coordinates relative to viewport
console.log(e.pageX, e.pageY); // coordinates relative to page
console.log(e.offsetX, e.offsetY); // coordinates relative to element
console.log(e.buttons); // pressed buttons (bitmask)
});
// Implementing drag
let isDragging = false;
let startX, startY;
canvas.addEventListener('mousedown', (e) => {
isDragging = true;
startX = e.clientX;
startY = e.clientY;
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
moveElement(dx, dy);
startX = e.clientX;
startY = e.clientY;
});
document.addEventListener('mouseup', () => {
isDragging = false;
});
// mouseenter/mouseleave (no bubbling)
// vs. mouseover/mouseout (with bubbling)
const menu = document.querySelector('.dropdown');
menu.addEventListener('mouseenter', () => menu.classList.add('open'));
menu.addEventListener('mouseleave', () => menu.classList.remove('open'));
Form Events
const form = document.querySelector('#signup-form');
// submit
form.addEventListener('submit', async (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(form));
await submitSignup(data);
});
// change: when value changes and focus leaves
document.querySelector('#country').addEventListener('change', (e) => {
updateCityOptions(e.target.value);
});
// input: every time value changes (real-time)
document.querySelector('#username').addEventListener('input', (e) => {
validateUsername(e.target.value);
});
// focus / blur
document.querySelector('#email').addEventListener('focus', (e) => {
e.target.parentElement.classList.add('focused');
});
document.querySelector('#email').addEventListener('blur', (e) => {
e.target.parentElement.classList.remove('focused');
validateEmail(e.target.value);
});
// focusin / focusout (with bubbling - useful for delegation)
form.addEventListener('focusin', (e) => {
if (e.target.matches('input, select, textarea')) {
e.target.parentElement.classList.add('focused');
}
});
form.addEventListener('focusout', (e) => {
if (e.target.matches('input, select, textarea')) {
e.target.parentElement.classList.remove('focused');
}
});
Real-World Example: Modal System
class Modal {
#element;
#focusableElements;
#previousFocus;
constructor(id) {
this.#element = document.getElementById(id);
this.#bindEvents();
}
#bindEvents() {
// Close button
this.#element.addEventListener('click', (e) => {
if (e.target.matches('[data-action="close"]') ||
e.target === this.#element) {
this.close();
}
});
// Close with ESC key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.isOpen) {
this.close();
}
});
// Focus trap (accessibility)
this.#element.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
this.#trapFocus(e);
}
});
}
open() {
this.#previousFocus = document.activeElement;
this.#element.classList.add('visible');
this.#element.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
this.isOpen = true;
// Focus the first focusable element
const firstFocusable = this.#element.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
this.#element.dispatchEvent(new CustomEvent('modal:opened', { bubbles: true }));
}
close() {
this.#element.classList.remove('visible');
this.#element.setAttribute('aria-hidden', 'true');
document.body.classList.remove('modal-open');
this.isOpen = false;
this.#previousFocus?.focus();
this.#element.dispatchEvent(new CustomEvent('modal:closed', { bubbles: true }));
}
#trapFocus(e) {
const focusableEls = this.#element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusableEls[0];
const lastFocusable = focusableEls[focusableEls.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
lastFocusable.focus();
e.preventDefault();
}
} else {
if (document.activeElement === lastFocusable) {
firstFocusable.focus();
e.preventDefault();
}
}
}
}
Expert Tips
1. Preventing Event Handler Memory Leaks
class Component {
#handlers = new Map();
addEvent(element, type, handler) {
const bound = handler.bind(this);
this.#handlers.set(`${type}`, { element, type, handler: bound });
element.addEventListener(type, bound);
}
destroy() {
// Remove all events when component is destroyed
this.#handlers.forEach(({ element, type, handler }) => {
element.removeEventListener(type, handler);
});
this.#handlers.clear();
}
}
2. Batch-Removing Events with AbortController
// Remove multiple events at once with AbortController
const controller = new AbortController();
const { signal } = controller;
element.addEventListener('click', handleClick, { signal });
element.addEventListener('mouseover', handleHover, { signal });
document.addEventListener('keydown', handleKey, { signal });
// Remove all events at once
controller.abort();
3. Event Debounce and Throttle
// Debounce: executes delay ms after the last call
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
// Throttle: executes at most once every delay ms
function throttle(fn, delay) {
let lastTime = 0;
return (...args) => {
const now = Date.now();
if (now - lastTime >= delay) {
lastTime = now;
fn(...args);
}
};
}
// Debounce search input (300ms)
const handleSearch = debounce((value) => {
fetchSearchResults(value);
}, 300);
searchInput.addEventListener('input', (e) => handleSearch(e.target.value));
// Throttle scroll (100ms)
const handleScroll = throttle(() => {
updateScrollIndicator();
}, 100);
window.addEventListener('scroll', handleScroll, { passive: true });