Events
In short: Events tell JavaScript that the user clicked or typed: addEventListener listens, most events bubble up the tree, and delegation uses one listener for many elements.
Problem
The new product is not added
The catalogue shows two products. "Show more" adds a third one. "Add" on the first two raises the cart, but "Add" on the new product does nothing. The console has no error.
Console
Click "Add" on the bread, then "Show more", then "Add" on the cheese. The cart stays at 1. The cheese button looks like the others and has the same text. What is it missing?
The idea
Event and listener
When the buyer clicks, presses a key or types into a field, the browser creates an event: an object that says what happened and where. The code waits for it with a listener:
const button = document.querySelector('#plus');button.addEventListener('click', (event) => { console.log(event.type, event.target);});- The first argument is the kind of event:
click,keydown,input,submit. - The second is a function. The browser calls it on every event and passes it the event object. Arrow functions come from the lesson on lambdas in the JavaScript course.
- You pass the function, you do not call it:
addEventListener('click', save), without parentheses.save()would call it right away.
The event object has fields:
event.targetis the element where the event happened: the button that was clicked.event.currentTargetis the element whose listener is running now.event.keyis the key onkeydown:'Enter','Escape','a'.
Now it is clear what is missing in the problem. The listeners are added once, while the page loads, to the buttons that exist at that moment. The cheese button is created later and has no listener.
Try it
Plus and minus
Make the quantity buttons: "+" raises the quantity by 1, and "−" lowers it by 1, but not below 1. The quantity is shown in #qty.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dobrudzha bread</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<h1>Dobrudzha bread</h1>
<p>
<button type="button" id="minus" aria-label="Fewer">−</button>
<output id="qty">1</output>
<button type="button" id="plus" aria-label="More">+</button>
</p>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; padding: 16px; }
button { font-size: 16px; padding: 6px 14px; }
output { display: inline-block; min-width: 2em; text-align: center; font-size: 20px; }
script.jsconst qty = document.querySelector('#qty');
let count = 1;
document.querySelector('#plus').addEventListener('click', () => {
count++;
qty.textContent = count;
});
document.querySelector('#minus').addEventListener('click', () => {
if (count > 1) count--;
qty.textContent = count;
});
Each button has its own listener with a function that changes count and shows it with textContent. On "−" the check if (count > 1) keeps it from reaching 0. If the function is separate, you pass it without parentheses: addEventListener('click', plus). With plus() it is called once while the page loads, the quantity becomes 2, and there is no listener.
The idea
The event bubbles up
The button is inside <li>, <li> is inside <ul>, and <ul> is inside <main>. A click on the button is also a click on each of them. So the event does not stay in the button. It starts at event.target and bubbles up through the parents to document and window. Every parent with a click listener hears it.
document.querySelector('ul').addEventListener('click', (event) => { console.log(event.target); // the button that was clicked console.log(event.currentTarget); // <ul>: the element with the listener});Most events bubble: click, keydown, input, submit. Some do not: focus and blur. For them there are focusin and focusout, which do bubble.
See it
See the click bubble up
On the left is the tree of the catalogue. The listener is on <ul>, and the click is on the milk's button.
const list = document.querySelector('#products');list.addEventListener('click', (event) => { const button = event.target.closest('button'); if (!button) return; const item = button.closest('li'); console.log('Add', item.dataset.id);});Step by step, in words
- There is one listener, and it is on the list <ul>, not on the buttons. It waits for click.
- The buyer clicks "Add" on the milk. The event starts at the innermost element under the cursor: the button. That is event.target.
- Then it bubbles up: from the button to its parent <li>, then to <ul>. Every parent hears the click too.
- <ul> has a listener, and the function runs. event.currentTarget is <ul>, the element with the listener. event.target is still the button.
- closest('button') starts at event.target itself and goes up. The target is a button, so it is found right away.
- closest('li') goes up from the button and stops at the first <li>: the milk. Its data-id says which product it is.
- dataset.id is "2". The cart knows what to add.
- The event goes on up to <main>, <body>, document and window. There are no listeners there, and nothing else happens.
- A click on the name of the bread also bubbles to <ul>. But the target is <h2>: closest('button') finds no button above it and returns null, and the function stops.
- One listener serves every button in the list, even the ones added later: they are inside <ul> too. This is called delegation.
A click on the name of the bread also reaches <ul>. That is why the listener first checks that a button was clicked.
The idea
Delegation
Instead of one listener per button, you put one on the common parent. It works out from event.target which button was clicked. This is called delegation.
list.addEventListener('click', (event) => { const button = event.target.closest('button'); if (!button) return; const id = button.closest('li').dataset.id; // …add product id});closest('button')starts at the element itself and goes up to the first one that matches the selector. With no match it returnsnull. Here it guards against a click on the name or the empty space in<li>.- The product data is on
<li>(data-id), not on the button.closest('li')gets to it.
There are two gains. New products work with nothing extra: they are in the list too. And there is one listener, however many products there are.
Try it
Fix the catalogue
Fix the catalogue with one listener on #products. "Add" on every product, including the one added with "Show more", raises the cart and writes "product added" and the data-id of the product to the console. A click on the name of a product adds nothing.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Catalogue</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<p id="count">Cart: 0</p>
<ul id="products">
<li data-id="1">
<h2>Dobrudzha bread</h2>
<button type="button">Add</button>
</li>
<li data-id="2">
<h2>Fresh milk 3%</h2>
<button type="button">Add</button>
</li>
</ul>
<button type="button" id="more">Show more</button>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; padding: 16px; }
button { font-size: 16px; padding: 6px 14px; }
#products { list-style: none; padding: 0; }
#products li { display: flex; align-items: center; gap: 12px; border-bottom: 1px solid #ddd; }
#products h2 { font-size: 18px; flex: 1; }
script.jsconst count = document.querySelector('#count');
let items = 0;
document.querySelector('#products').addEventListener('click', (event) => {
const button = event.target.closest('button');
if (!button) return;
items++;
count.textContent = `Cart: ${items}`;
console.log('product added', button.closest('li').dataset.id);
});
document.querySelector('#more').addEventListener('click', () => {
const li = document.createElement('li');
li.dataset.id = '3';
const title = document.createElement('h2');
title.textContent = 'White cheese';
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Add';
li.append(title, button);
document.querySelector('#products').append(li);
});
The listener is on #products, so it also hears the cheese button. event.target.closest('button') is null when the click is on <h2>, and the function ends. event.target.dataset.id is undefined: data-id is on <li>, so it is button.closest('li').dataset.id.
The idea
The default action
Some events have an action that the browser does by itself, after the listeners. A click on a link opens its address. submit on a form sends the fields and loads the new page. This is the default action.
The search form here shows the result in the page. Try it.
Console
The listener writes "You are looking for: milk", but then the browser sends the form. In a real browser the page loads again with ?q=milk in the address, and the result is gone. The sandbox is a single page: it stops the sending and writes "The form was sent" to the console.
Add event.preventDefault(); at the start of the function and run it again. The line in the console is gone: the action is stopped, and the listener still does its job. The same goes for a link that opens something inside the page instead of going to another address.
event.stopPropagation() stops the bubbling. You rarely need it, and it is usually a bad sign. It also stops listeners the code does not know about: the delegation above, closing a menu on a click outside it, counting clicks. If a listener "hears too much", check event.target, the way closest does in delegation.
Try it
Find the bug
The listener shows the result, but the form is still sent.
Pick the line with the bug.
event.preventDefault without parentheses only reads the method and does not call it. It should be event.preventDefault();.
The idea
input or change
Fields have two events for a change:
inputcomes with every change of the value: every letter, deletion, paste.changecomes when the buyer is done. For a text field that is when they leave it. For a checkbox, a radio button and a drop-down it is right after the choice.
For a search that filters while you type, use input. For a "Sort by price" drop-down, use change.
More precisely, if you are curious
A third argument. addEventListener('click', f, { once: true }) listens only once. removeEventListener('click', f) removes the listener, but only if you pass the same function. An arrow function written again is a different function.
onclick. button.onclick = f also works, but it is a single field: a second onclick erases the first. addEventListener allows many listeners. The onclick="…" attribute in HTML mixes the code with the page, and a strict security policy (CSP) blocks it.
Bubbling and capturing. The path of the event has another direction too: first from window down to the target (capturing), then up (bubbling). addEventListener('click', f, { capture: true }) listens on the way down. You almost always want bubbling.
The keyboard. A <button> gets click from Enter and Space too. That is why click on a button works with the keyboard. keydown on a <div> does not replace it (see the accessibility lesson).
event.key versus event.code. key is the character ('a' on a QWERTY keyboard, 'q' for the same key on a French AZERTY one), and code is where the key is ('KeyA' on both). For shortcuts like Escape and Enter, use key.
Exercise
Live search
Make the search filter the list while the buyer types: only the products that contain the text are shown, whatever the case of the letters. A hidden product has hidden. #found shows how many are shown ("Found: 2"). Escape in the field clears it and shows them all. Enter does not send the form.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Catalogue</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<form id="search">
<label>Search <input id="q" autocomplete="off"></label>
</form>
<p id="found">Found: 4</p>
<ul id="products">
<li>Dobrudzha bread</li>
<li>Fresh milk 3%</li>
<li>Chocolate milk</li>
<li>White cheese</li>
</ul>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; padding: 16px; }
button { font-size: 16px; padding: 6px 14px; }
script.jsconst input = document.querySelector('#q');
const found = document.querySelector('#found');
const items = document.querySelectorAll('#products li');
function filter() {
const q = input.value.trim().toLowerCase();
let shown = 0;
items.forEach((li) => {
const match = li.textContent.toLowerCase().includes(q);
li.hidden = !match;
if (match) shown++;
});
found.textContent = `Found: ${shown}`;
}
input.addEventListener('input', filter);
input.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
input.value = '';
filter();
}
});
document.querySelector('#search').addEventListener('submit', (event) => {
event.preventDefault();
});
The filter function goes over every <li>, compares its text with the search (both with toLowerCase()) and sets li.hidden. It runs on input: on every letter. With change the list does not change while the buyer types. Escape is keydown with event.key === 'Escape': an empty field and filter() again. The solution also stops Enter with preventDefault() on submit, so the form is not sent. The check presses Enter and looks at the console: the sandbox does not load the page again, but it writes when the form is sent.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Text, links, images, tables · Forms · Selectors and the cascade · The DOM: the page from JavaScript
- Continues in: A list from data · Data from an API · The first component · Lists and keys
- Tasks on this topic: JavaScript in the page