Learn by reading through in order

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
The Lines Where addToCart Actually Runs
addEventListener("click", addToCart)cartButton.click()(1st)cartButton.click()(2nd)Only registers;count stays 0addToCart runs;count becomes 1Called again;count becomes 2
The registration line doesn't call addToCart, so the count stays at 0. The function body runs on the lines where a click happens.

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 ().

On an author page, switch what the follow button shows each time it's clicked. The button followButton is already declared.

① Register a function that toggles the active class on each click and swaps the text between "Follow" and "Following".

② Trigger one click, then print the button's HTML, including its tag.

③ Trigger another click, then print the button's HTML.

(If it runs correctly, an explanation will appear.)

JavaScript / TypeScript Editor

Run code to see output

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
How the Click on A-102 Travels
section element — its listener is called second
  • event.target.dataset.id is A-102
ul element — its listener is called first
  • event.target.dataset.id is A-102
A-101 li — not clicked
  • The click doesn't reach sibling li elements
A-102 li — the element click() was called on
  • event.target is this li
  • The click starts traveling outward from here
The click travels from the li to the ul, then to the section, and never passes through the sibling li. event.target is the A-102 li everywhere.

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.

On a meeting room booking screen, handle the time slot that's clicked in the list. The ul element for the list of slots, slotList, is already declared.

① Register just one listener on the list that prints the clicked slot's time and adds the selected class to it.

② Trigger a click on the 10:00 slot.

③ Create a 16:00 slot, add it to the end of the list, and trigger a click on it.

④ Print the list's HTML, including the ul tag.

JavaScript / TypeScript Editor

Run code to see output

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
A Click That Travels vs. One That's Stopped
favoriteButton.click()Prints "Addedto favorites"Not stopped, so itreaches the row li"Open details"is printed toodeleteButton.click()stopPropagation(),then logs deletionDoesn't reachthe row li"Open details"isn't printed
Favorite doesn't stop the click, since opening the details is fine, so it reaches the row. Once you call stopPropagation(), the row's listener isn't called.

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.

In a team chat's notification list, clicking a row opens the notification, while the mute button inside the row only mutes it. The notification row noticeRow, which already has a listener, is already declared.

① Register a listener on the mute button that prints "Muted" and keeps the click from reaching the row.

② Trigger a click on the mute button.

③ Print the row's HTML, including the li tag.

④ Trigger a click on the row itself.

JavaScript / TypeScript Editor

Run code to see output

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
Is It Called on the Next Click After Removal?
confirmButton:3 ways to removeRegister with{ once: true }removeEventListener("click", sendOrder)removeEventListener("click", () => …)Auto-removedafter one callSame function,so it's removed=== is false,so not removedNot called onthe next clickNot called onthe next clickNext click stilllogs "Order sent"
Using once, or passing the same function you registered, removes the listener. Passing a newly written arrow function doesn't remove it.

() => … 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.

CodeWhat It DoesWatch Out For
addEventListener("click", fn)Calls fn on every clickPass fn without ()
event.targetThe element clickedInnermost one, even on a parent
One on the parent (delegation)Handles all child clicksCovers children added later
event.stopPropagation()Stops the outward travelDelegating parents miss it too
{ once: true }Removes it after one call3rd arg of addEventListener
removeEventListener("click", fn)Removes the listenerPass the same function

This is the sign-up screen for a webinar. registerButton, waitButton, attendeeCount, and waitingCount are already declared.

① Register a listener on the register button that adds 1 to the attendee count only once.

② Trigger two clicks on the register button and print the count.

③ Register a function that adds 1 person to the waitlist, then trigger a click.

④ Remove the function from ③, trigger another click, and print the count.

JavaScript / TypeScript Editor

Run code to see output
QUIZ

Knowledge Check

Answer each question one by one.

Q1With saveButton.addEventListener("click", save());, when is save called?

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())?