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
onclickattributes - 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:
- Capturing — from the root (
document) down to the target element. - 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
addEventListeneris 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
- Build the counter widget above, then add a rule that disables the decrement button once
countreaches 0. - 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). - Explain the difference between
event.stopPropagation()andevent.preventDefault()in your own words, with one example where you'd want each.
Hints
- Add an
if (count <= 0) return;guard to the decrement handler, and toggle adisabledattribute on the button insiderender(). - Check
event.target.matches('.delete-btn')inside the same delegated listener, and call.closest('li').remove()when it matches. stopPropagationis about which listeners run (e.g., stopping a click inside a modal from also closing it via a parent's listener);preventDefaultis 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.