Events and Event Delegation

โ–ถ Try It Yourself

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
Note: 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.
Tip: Event delegation is one of the most important patterns in DOM programming. Instead of attaching a listener to every item in a list, attach one listener to the parent. When any child is clicked, the event bubbles up to the parent where you handle it. This works for dynamically added items too โ€” since the listener is on the parent, new children are automatically handled without re-attaching listeners.
Warning: Always remove event listeners you no longer need โ€” especially on elements that are removed from the DOM. A listener on a removed element keeps both the listener function and the element alive in memory (preventing garbage collection). Use 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();

▶ Try It Yourself

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

🧠 Test Yourself

Why is event delegation better than attaching a listener to every list item?





โ–ถ Try It Yourself