Skip to main content

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 });