Q1With saveButton.addEventListener("click", save());, when is save called?
Events — addEventListener and Propagation
Handle clicks with addEventListener and event.target, delegate them through bubbling, and use stopPropagation, once, and removeEventListener.
Assigning to textContent updates an element once, at the moment that line runs. Code that bumps a count whenever a button is clicked can't be written as lines that simply run from top to bottom, because the code has no way of knowing when the click will happen.
This article covers addEventListener, which calls a function when something is clicked, and how to handle clicks on child elements from their parent.
Calling a Function on Click — addEventListener
Suppose a product page's "Add to cart" button should add 1 to the cart count and show the new total on every click. If you simply write the line that updates the count, it runs once, when the page loads, and later clicks on the button don't change the count.
To have a function called when an event (something that happens on the page, like a click or an input) occurs, you register it by passing the event name and the function, as in addEventListener("click", function). The registered function is called an event listener (or just listener). You can't click with a mouse in the console, so you trigger clicks with the click method, as in cartButton.click().
document.body.innerHTML = `<button id="add-to-cart">Add to cart</button><p>Cart: <span id="cart-count">0</span> items</p>`;
const cartButton = document.querySelector("#add-to-cart");
const cartCount = document.querySelector("#cart-count");
// The function to call on click (only defined here)
function addToCart() {
cartCount.textContent = Number(cartCount.textContent) + 1;
}
// Pass the function itself to register it (addToCart isn't called on this line)
cartButton.addEventListener("click", addToCart);
console.log(cartCount.textContent); // 0
// Every time you trigger a click, the registered addToCart is called
cartButton.click();
cartButton.click();
console.log(cartCount.textContent); // 2
On a real page, you don't write click(): the browser calls addToCart as soon as the user clicks the button. It only responds to clicks that happen after it's registered; clicks made before the registration line are ignored.
Adding () Makes It Run Once, on the Registration Line
If you write addEventListener("click", addToCart()), addToCart is called once on the registration line, and its return value, undefined, is what gets passed. No error appears, so it's hard to figure out why clicking doesn't increase the count. When registering, pass just the function name, without ().
Handling a Child's Click on Its Parent — Bubbling and Delegation
Next, when a row in the order list is clicked, you want to read that order's number. If you collect the rows with querySelectorAll and register a listener on each one with forEach, only the rows on the page at that moment get a listener, so clicking a row added later with append does nothing.
Thanks to bubbling (the way an event that happens on an element travels up to its parent, then that element's parent, and so on), a listener registered on the parent ul is called when an li is clicked, too. The browser passes the listener an event object (an object holding information about the event that occurred) as an argument. In the code below, the argument is named event, and its target gives you the element where the click originally happened.
document.body.innerHTML = `<section id="order-panel"><ul id="order-list"><li data-id="A-101">Order A-101</li><li data-id="A-102">Order A-102</li></ul></section>`;
const orderList = document.querySelector("#order-list");
const orderPanel = document.querySelector("#order-panel");
// Register on the parent ul and the section around it, not on the li elements
orderList.addEventListener("click", (event) => {
console.log(`Handled on ul: ${event.target.dataset.id}`);
});
orderPanel.addEventListener("click", (event) => {
console.log(`Handled on section: ${event.target.dataset.id}`);
});
// Trigger a click on the A-102 li
document.querySelector('[data-id="A-102"]').click();
// Handled on ul: A-102
// Handled on section: A-102
event.target.dataset.idis A-102
event.target.dataset.idis A-102
- The click doesn't reach sibling li elements
event.targetis this li- The click starts traveling outward from here
Registering just one listener on the parent and using event.target to tell which child was clicked is called event delegation. An li's click is handled after it has traveled up to the ul, so the same listener is called even for an li added with append after registration. event.target is the innermost element that was clicked, so if the li contains a span, it points to the span.
Stopping a Click from Reaching the Parent — stopPropagation
Consider an order row that opens its details when clicked and contains a "Delete" button that deletes the order. The button is a child of the row, so a click on "Delete" bubbles up to the row too, and the details open even as the order is being deleted.
An event traveling to outer elements is called propagation. stopPropagation (a method that stops propagation at the element whose listener is running) is called as event.stopPropagation() inside the button's listener. Once it's stopped, listeners registered on outer elements, like the row or the list, aren't called.
document.body.innerHTML = `<ul><li id="order-row">Order A-201 <button id="favorite">Favorite</button><button id="delete">Delete</button></li></ul>`;
const orderRow = document.querySelector("#order-row");
const favoriteButton = document.querySelector("#favorite");
const deleteButton = document.querySelector("#delete");
// Clicking the row opens its details
orderRow.addEventListener("click", () => console.log("Open details"));
// Favorite: opening the details is fine too, so let the click reach the row
favoriteButton.addEventListener("click", () => console.log("Added to favorites"));
favoriteButton.click(); // Added to favorites, Open details (2 lines)
// Delete: call stopPropagation() so the click doesn't reach the row
deleteButton.addEventListener("click", (event) => {
event.stopPropagation();
console.log("Order deleted");
});
deleteButton.click(); // Order deleted
In the delete listener, "Order deleted" is still printed even though it comes after stopPropagation(). What stops is the event traveling to outer elements, not the rest of the listener that called it.
A Stopped Click Doesn't Reach a Delegating ul Either
stopPropagation() stops the event from reaching not just the row directly around the button, but every element beyond it. Even if the list's ul has a delegated listener that logs user actions, clicks on the delete button won't be logged. Only stop clicks that no outer element needs to handle.
Removing a Listener — once and removeEventListener
If a user clicks the "Place order" button twice in a row, the registered listener is called twice, and the same order is sent twice. Once the first submission has gone through, you need to make sure the listener isn't called when the same button is clicked again.
If you pass { once: true } as the third argument to addEventListener, the listener is removed automatically after it's called once. To remove a listener whenever you like, call removeEventListener (a method that takes an event name and a function, finds the registered listener that's === to that function, and removes it).
document.body.innerHTML = `<button id="confirm">Place order</button>`;
const confirmButton = document.querySelector("#confirm");
const sendOrder = () => console.log("Order sent");
// { once: true } — removed automatically after being called once
confirmButton.addEventListener("click", sendOrder, { once: true });
confirmButton.click(); // Order sent
confirmButton.click(); // (nothing is printed)
// Passing the registered sendOrder removes that listener
confirmButton.addEventListener("click", sendOrder);
confirmButton.removeEventListener("click", sendOrder);
confirmButton.click(); // (nothing is printed)
// A newly written arrow function is a different function, even with the same body, so nothing is removed
confirmButton.addEventListener("click", () => console.log("Order sent"));
confirmButton.removeEventListener("click", () => console.log("Order sent"));
confirmButton.click(); // Order sent
() => … creates a new function every time you write it, so even if the body is the same, comparing it with the registered function using === gives false. Put any listener you'll remove later into a variable first, like sendOrder, and register that. The table below sums up the event techniques covered in this article.
| Code | What It Does | Watch Out For |
|---|---|---|
| addEventListener("click", fn) | Calls fn on every click | Pass fn without () |
| event.target | The element clicked | Innermost one, even on a parent |
| One on the parent (delegation) | Handles all child clicks | Covers children added later |
| event.stopPropagation() | Stops the outward travel | Delegating parents miss it too |
| { once: true } | Removes it after one call | 3rd arg of addEventListener |
| removeEventListener("click", fn) | Removes the listener | Pass the same function |
Knowledge Check
Answer each question one by one.
Q2Inside a listener registered on a ul, what is event.target when you call click() on an li inside that ul?
Q3After registering () => send(), what happens when you call removeEventListener("click", () => send())?