Skip to main content
Adapted, not copied

Original notes for JavaScript360.org — topic order inspired by Eloquent JavaScript (4th ed.) by Marijn Haverbeke. See the full attribution note.

Chapter 15: Handling Events

Estimated time: ~35 minutes · Chapter type: Concept

What you'll learn

  • Registering event listeners, and why they beat inline onclick attributes
  • How events bubble and capture through the DOM tree
  • Event delegation — handling many elements' events with one listener
  • A couple of small, complete interactive examples

Listening for events

addEventListener attaches a function that runs whenever a given event occurs on an element:

const button = document.querySelector('#save');
button.addEventListener('click', () => {
console.log('Saved!');
});

This is strongly preferred over the older onclick="..." HTML attribute style: you can attach multiple independent listeners to the same event, remove a listener later with removeEventListener, and keep behavior (JavaScript) separate from structure (HTML) — the same separation-of-concerns principle from Chapter 13.

function handleSave() { console.log('Saved!'); }
button.addEventListener('click', handleSave);
button.removeEventListener('click', handleSave); // must be the *same* function reference

The event object

Every listener receives an event object describing what happened — which key was pressed, where the mouse was, which element triggered it, and more:

document.addEventListener('keydown', (event) => {
console.log(event.key); // e.g. "Enter", "a", "ArrowUp"
console.log(event.target); // the actual element that received the keystroke
});

button.addEventListener('click', (event) => {
console.log(event.clientX, event.clientY); // mouse position when clicked
});

Propagation: bubbling and capturing

When an event fires on an element nested inside others, it doesn't just run on that one element — it travels through the tree in two phases:

  1. Capturing — from the root (document) down to the target element.
  2. Bubbling — from the target back up to the root.

By default, addEventListener listens during the bubbling phase, which is almost always what you want: an event on a deeply nested <button> will also trigger click listeners on its parent <div>, then the <body>, and so on, unless something stops it.

document.querySelector('.card').addEventListener('click', () => console.log('card clicked'));
document.querySelector('.card button').addEventListener('click', () => console.log('button clicked'));

// Clicking the button logs both, in this order:
// "button clicked" (target itself, fires first)
// "card clicked" (bubbled up to the parent)

event.stopPropagation() stops an event from bubbling further, and event.preventDefault() cancels the browser's default behavior for that event (like following a link, or submitting a form) — two different things that are frequently confused:

document.querySelector('form').addEventListener('submit', (event) => {
event.preventDefault(); // stop the page from reloading — handle the submission in JS instead
console.log('Handling submission manually');
});

Event delegation

Bubbling isn't just a quirk to work around — it enables a genuinely useful pattern. Instead of attaching a listener to every single item in a list (which also means re-attaching one every time you add a new item), attach one listener to their shared parent, and inspect event.target to figure out which child was actually clicked:

const list = document.querySelector('#tasks');

list.addEventListener('click', (event) => {
const item = event.target.closest('li'); // finds the nearest <li> ancestor, if any
if (!item) return; // click landed somewhere else inside the list, not on an item

item.classList.toggle('done');
});

This one listener correctly handles every current <li>, plus any added later — no re-binding needed, and dramatically less memory used than one listener per row in a list with thousands of items. Element.prototype.closest() walks up from the clicked element looking for the nearest ancestor matching a selector, which is what makes delegation clean even when the actual click lands on something inside an item (like an icon inside the <li>).

A small complete example: a counter widget

<button id="decrement"></button>
<span id="count">0</span>
<button id="increment">+</button>
let count = 0;
const countLabel = document.getElementById('count');

function render() {
countLabel.textContent = String(count);
}

document.getElementById('increment').addEventListener('click', () => {
count += 1;
render();
});

document.getElementById('decrement').addEventListener('click', () => {
count -= 1;
render();
});

Small as it is, this example demonstrates the pattern behind nearly every interactive UI: state (count) changes in response to an event, and a render function syncs the DOM to match the new state. Frameworks like React automate the "render" step; understanding it manually here is what makes those frameworks make sense later.

Key takeaways

  • addEventListener is the standard way to respond to user interaction; prefer it over inline HTML event attributes.
  • Events bubble from the target element up through its ancestors by default — stopPropagation() halts that, preventDefault() cancels the browser's default action, and they're not the same thing.
  • Event delegation — one listener on a shared parent, dispatching based on event.target — scales far better than one listener per child element.
  • The state → event → re-render loop in the counter example is the mental model underneath essentially every interactive JavaScript UI.

Try it yourself

  1. Build the counter widget above, then add a rule that disables the decrement button once count reaches 0.
  2. Rewrite the task-list toggle example so that clicking a "delete" button inside an <li> removes that item, without adding a separate listener to each delete button (keep using delegation).
  3. Explain the difference between event.stopPropagation() and event.preventDefault() in your own words, with one example where you'd want each.
Hints
  1. Add an if (count <= 0) return; guard to the decrement handler, and toggle a disabled attribute on the button inside render().
  2. Check event.target.matches('.delete-btn') inside the same delegated listener, and call .closest('li').remove() when it matches.
  3. stopPropagation is about which listeners run (e.g., stopping a click inside a modal from also closing it via a parent's listener); preventDefault is about the browser's built-in behavior (e.g., stopping a form's default full-page reload on submit).

Summary

Events are what turn a static page into something that responds to a person, and delegation is the pattern that lets that scale to real applications with dynamic, changing content. With the DOM and events both covered, you have everything you need for the next project: a small, actual game.

 

🌟 Join the JavaScript360 community

Connect with fellow JS developers, and get free interview prep, React challenges, and DSA guides.

Join on WhatsApp →