Events are how JavaScript responds to user interaction and browser activity โ clicks, key presses, form submissions, window resizes, and much more. Understanding the event system deeply โ how events propagate through the DOM, how to intercept and redirect them, and the delegation pattern that enables efficient handling of dynamic content โ is the most important DOM skill for building interactive web applications. In this lesson you will master addEventListener, event propagation, event delegation, removing listeners, and the most useful event types.
addEventListener vs Inline Handlers
| Approach | Syntax | Multiple Handlers? | Recommended? |
|---|---|---|---|
| addEventListener | el.addEventListener('click', fn) |
Yes โ unlimited | Yes โ always use this |
| Inline HTML | <button onclick="fn()"> |
No โ only one | No โ mixes HTML and JS |
| Property assignment | el.onclick = fn |
No โ overwrites previous | No โ only for legacy code |
Event Propagation Phases
| Phase | Direction | Description |
|---|---|---|
| 1. Capture | Window โ target | Event travels down โ rarely used; opt-in with { capture: true } |
| 2. Target | At the element | Event fires on the exact element that was interacted with |
| 3. Bubble | Target โ window | Event travels up โ default for almost all events |
Key Event Object Properties and Methods
| Property / Method | Description |
|---|---|
e.target |
Element that was actually clicked / interacted with |
e.currentTarget |
Element the listener is attached to |
e.type |
Event type string: 'click', 'keydown', etc. |
e.preventDefault() |
Stop the browser’s default action (form submit, link navigation) |
e.stopPropagation() |
Stop event from bubbling further up the tree |
e.stopImmediatePropagation() |
Also prevent other listeners on the same element |
e.key |
Key name for keyboard events: 'Enter', 'Escape', 'ArrowUp' |
e.clientX / e.clientY |
Mouse position relative to viewport |
e.shiftKey / e.ctrlKey / e.altKey |
Modifier keys held during event |
Common Events Reference
| Category | Events | Notes |
|---|---|---|
| Mouse | click, dblclick, mousedown, mouseup, mousemove, mouseenter, mouseleave |
mouseenter/leave do not bubble |
| Keyboard | keydown, keyup, keypress(deprecated) |
Use e.key, not e.keyCode |
| Form | submit, change, input, focus, blur, reset |
input fires on every keystroke; change fires on blur |
| Window | load, DOMContentLoaded, resize, scroll, beforeunload |
DOMContentLoaded fires before images load |
| Pointer | pointerdown, pointermove, pointerup |
Unified mouse + touch + pen |
e.target and e.currentTarget are different. e.target is the element that was actually clicked โ it may be a child deep inside a container. e.currentTarget is always the element the listener is attached to. In event delegation, you attach the listener to a parent but use e.target.closest(selector) to find which child was actually interacted with.removeEventListener with the exact same function reference, or use an AbortController to cancel a group of listeners at once.Basic Example
// โโ addEventListener โ the right way โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const btn = document.querySelector('#submit-btn');
function handleClick(e) {
console.log('Clicked!', e.target, e.type);
}
btn.addEventListener('click', handleClick);
// Remove listener โ must use same function reference
btn.removeEventListener('click', handleClick);
// โโ Event object properties โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
document.querySelector('form').addEventListener('submit', (e) => {
e.preventDefault(); // stop form from reloading the page
console.log('Form submitted โ preventDefault called');
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
if (e.key === 'Enter' && e.ctrlKey) submitForm();
if (e.key === 'ArrowUp') e.preventDefault(); // prevent scroll
});
// โโ Event bubbling โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Click on SPAN โ fires on span, li, ul, body, html, document, window
document.querySelector('ul').addEventListener('click', (e) => {
console.log('target:', e.target.tagName); // SPAN (innermost clicked)
console.log('currentTarget:', e.currentTarget.tagName); // UL (where listener is)
});
// stopPropagation โ prevent bubbling further
document.querySelector('.modal').addEventListener('click', (e) => {
e.stopPropagation(); // click inside modal does not close it
});
document.addEventListener('click', () => closeModal()); // click outside closes
// โโ Event delegation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// ONE listener handles ALL items โ including dynamically added ones
document.querySelector('#todo-list').addEventListener('click', (e) => {
const deleteBtn = e.target.closest('.delete-btn');
const item = e.target.closest('.todo-item');
if (deleteBtn && item) {
item.remove();
return;
}
if (item && !deleteBtn) {
item.classList.toggle('done');
}
});
// โโ AbortController โ cancel multiple listeners at once โโโโโโโโโโโโโโโโโโโ
const controller = new AbortController();
const { signal } = controller;
document.addEventListener('click', handleClick, { signal });
document.addEventListener('keydown', handleKeyDown, { signal });
document.addEventListener('scroll', handleScroll, { signal });
// Cancel all three at once โ perfect for cleanup in SPAs
controller.abort();
// โโ once โ auto-remove after first trigger โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
btn.addEventListener('click', () => {
console.log('This fires only once');
}, { once: true });
// โโ Custom events โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Dispatch
const event = new CustomEvent('cart:update', {
detail: { count: 3, total: 49.99 },
bubbles: true,
});
document.dispatchEvent(event);
// Listen
document.addEventListener('cart:update', (e) => {
document.querySelector('#cart-count').textContent = e.detail.count;
document.querySelector('#cart-total').textContent = `$${e.detail.total}`;
});
How It Works
Step 1 โ Events Bubble Up the DOM Tree
When you click a <span> inside a <li> inside a <ul>, the click event fires on the <span> first, then bubbles up to <li>, then <ul>, then <body>, then <html>, then document, then window. Any element with a click listener anywhere along this path will receive the event.
Step 2 โ Delegation Exploits Bubbling
By attaching one listener to a parent element, you catch events from all its descendants via bubbling. When the event arrives at the parent, e.target tells you which child was actually clicked. e.target.closest('.todo-item') finds the nearest ancestor matching the selector โ handling clicks on any child element inside a list item.
Step 3 โ preventDefault vs stopPropagation
These two methods do different things. preventDefault() stops the browser’s built-in behaviour for the event (form submission, link navigation, checkbox toggle). stopPropagation() stops the event from travelling further up the DOM tree. Both can be called together or independently โ they control separate behaviours.
Step 4 โ AbortController Cleans Up Groups of Listeners
When a component mounts, attach all its listeners with a shared signal. When the component unmounts, call controller.abort() โ all listeners are removed in one call. This is much cleaner than tracking every removeEventListener call individually, especially for components that attach listeners to document or window.
Step 5 โ Custom Events Decouple Components
CustomEvent lets you define your own event types with a detail payload. A shopping cart can dispatch 'cart:update'; a header component listens for it and updates the count badge โ neither component needs a reference to the other. This pub/sub pattern keeps components loosely coupled.
Real-World Example: Sortable Table with Delegation
// sortable-table.js
class SortableTable {
#data;
#sortKey = null;
#sortDir = 'asc';
#table;
constructor(selector, data) {
this.#data = data;
this.#table = document.querySelector(selector);
this.#render();
this.#attachEvents();
}
#attachEvents() {
// ONE listener on thead โ handles ALL header clicks via delegation
this.#table.querySelector('thead').addEventListener('click', (e) => {
const th = e.target.closest('th[data-sort]');
if (!th) return;
const key = th.dataset.sort;
if (this.#sortKey === key) {
this.#sortDir = this.#sortDir === 'asc' ? 'desc' : 'asc';
} else {
this.#sortKey = key;
this.#sortDir = 'asc';
}
this.#render();
});
}
#getSortedData() {
if (!this.#sortKey) return this.#data;
return [...this.#data].sort((a, b) => {
const v1 = a[this.#sortKey];
const v2 = b[this.#sortKey];
const cmp = typeof v1 === 'string'
? v1.localeCompare(v2)
: v1 - v2;
return this.#sortDir === 'asc' ? cmp : -cmp;
});
}
#render() {
const thead = this.#table.querySelector('thead tr');
thead.querySelectorAll('th[data-sort]').forEach(th => {
const isActive = th.dataset.sort === this.#sortKey;
th.classList.toggle('sort-active', isActive);
th.dataset.dir = isActive ? this.#sortDir : '';
});
const tbody = this.#table.querySelector('tbody');
const frag = document.createDocumentFragment();
this.#getSortedData().forEach(row => {
const tr = document.createElement('tr');
Object.values(row).forEach(val => {
const td = document.createElement('td');
td.textContent = val; // safe
tr.appendChild(td);
});
frag.appendChild(tr);
});
tbody.replaceChildren(frag);
}
}
const table = new SortableTable('#users-table', [
{ name: 'Alice', age: 30, dept: 'Engineering' },
{ name: 'Bob', age: 25, dept: 'Marketing' },
{ name: 'Carol', age: 35, dept: 'Engineering' },
]);
Common Mistakes
Mistake 1 โ Anonymous function prevents removeEventListener
โ Wrong โ cannot remove an anonymous listener:
el.addEventListener('click', () => doSomething());
el.removeEventListener('click', () => doSomething()); // different function ref โ does nothing!
โ Correct โ use named reference or AbortController:
const handler = () => doSomething();
el.addEventListener('click', handler);
el.removeEventListener('click', handler); // same reference โ works
Mistake 2 โ Attaching listeners inside a render loop
โ Wrong โ adds a new listener every time the list renders:
function render() {
items.forEach(item => {
const btn = createItemButton(item);
btn.addEventListener('click', () => deleteItem(item.id));
list.appendChild(btn);
});
}
// Called 10 times = 10 listeners per button โ memory leak!
โ Correct โ use event delegation on the stable parent:
list.addEventListener('click', e => {
const btn = e.target.closest('.delete-btn');
if (btn) deleteItem(btn.dataset.id);
});
Mistake 3 โ Using e.keyCode instead of e.key
โ Wrong โ keyCode is deprecated and hard to read:
if (e.keyCode === 13) submitForm(); // 13 = Enter โ but is it obvious?
โ Correct โ use the readable e.key string:
if (e.key === 'Enter') submitForm();
Quick Reference
| Task | Code |
|---|---|
| Add listener | el.addEventListener('click', fn) |
| Remove listener | el.removeEventListener('click', fn) |
| Fire once only | el.addEventListener('click', fn, { once: true }) |
| Prevent default | e.preventDefault() |
| Stop bubbling | e.stopPropagation() |
| Event delegation | parent.addEventListener('click', e => e.target.closest('.item')) |
| Cancel group | new AbortController(); el.addEventListener('x', fn, { signal }) |
| Custom event | new CustomEvent('name', { detail: {}, bubbles: true }) |