EduFlow
JavaScript Fundamentals
0%

Section 4 · JavaScript in the Browser

Events and Event Delegation

17 min3 min readCode playground

Lesson at a glance

Respond to clicks and key presses with addEventListener, read the event object, understand bubbling, and handle a whole list with one delegated listener that keeps working as items come and go.

  1. 1Listening for an event
  2. 2Data first, then render
  3. 3Bubbling: events travel up the tree
  4. 4Event delegation: one listener for the whole list

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Pocket can draw its list. Now people want to do things to it: delete an expense, mark one as reviewed, filter by category. Each of those starts with the user doing something, such as clicking or typing, and your code reacting. The browser reports everything users do as events.

Listening for an event

You ask an element to tell you about an event with addEventListener:

app.js
const clearButton = document.querySelector("#clear");

clearButton.addEventListener("click", (event) => {
  console.log("Clear was clicked", event.type);
});

Read it as: “when a click happens on this button, call this function”. Your function does not run now. The browser stores it and calls it later, once per click, passing an event object with details about what happened. This is the callback idea from the functions lesson, and the browser is the code calling you back.

The event types you will use most: click, input (a text field’s value changed), change (a value was committed, for example a select), submit (a form was sent), and keydown (a key was pressed; event.key says which, such as "Enter" or "Escape").

Data first, then render

The most important habit with events: do not treat the page as the place your data lives. Keep the data in a variable, change the variable in your handler, and call the render function from the last lesson to redraw:

app.js
let expenses = [
  { id: "exp-1", label: "Coffee", amount: 450 },
  { id: "exp-2", label: "Train", amount: 1220 },
];

clearButton.addEventListener("click", () => {
  expenses = [];
  renderExpenses(expenses);
});

The handler never touches <li> elements directly. It changes the data, and the render function makes the page match. However many buttons you add, there is still one place that draws the list, so the total and the list can never disagree.

Bubbling: events travel up the tree

When you click a button inside a list item inside a list, you are clicking all of them: the button, the <li>, the <ul>, the <body>. The browser models this by delivering the event first to the element you clicked, the target, and then to each ancestor in turn, all the way up to document. This is called bubbling.

A click on a delete button starts at the button, the event target, then bubbles up to the li, then to the ul where a delegated listener handles it, then on to body and document. body ul#expense-list (listener here) li data-id="l-js-4-2-exp-2" Train $12.20 button.delete target: where the click started
A click bubbles from the clicked element up through its ancestors

Inside a handler, event.target is the element where the event started, and event.currentTarget is the element whose listener is running now. A listener on the <ul> therefore hears clicks on every button inside it and can still find out which button it was.

Event delegation: one listener for the whole list

Suppose every expense has a delete button. You could attach a listener to each button, but the render function throws those buttons away and makes new ones every time the list changes, and the new buttons have no listeners. Instead, attach one listener to the list, which is never replaced, and work out what was clicked:

app.js
const list = document.querySelector("#expense-list");

list.addEventListener("click", (event) => {
  const button = event.target.closest("button.delete");
  if (!button) {
    return; // the click was somewhere else in the list
  }
  const id = button.closest("li").dataset.id;
  expenses = expenses.filter((e) => e.id !== id);
  renderExpenses(expenses);
});

closest(selector) starts at an element and walks up through its ancestors until it finds one that matches, or returns null. That handles clicks on an icon inside the button, and the if (!button) guard ignores clicks on the rest of the item. The id comes from the data-id you set while rendering, and the data change is a filter, exactly as in section 3.

This pattern is called event delegation. It means fewer listeners, and it keeps working for items added after the listener was set up, because their clicks bubble to the same list.

To stop a click from bubbling further you can call event.stopPropagation(), but you will rarely need it, and using it often breaks delegation somewhere else. event.preventDefault() is different and far more common: it cancels the browser’s default action, such as following a link or submitting a form. That second one is the star of the next lesson.

Key takeaways

  • addEventListener(type, handler) registers a function the browser calls later, with an event object describing what happened.
  • Pass the handler itself, without parentheses; writing handler() calls it immediately and registers its return value instead.
  • Events bubble from the element that was clicked up through its ancestors, so a listener on a parent hears clicks on its children.
  • With event delegation, one listener on a container uses event.target.closest(...) to find which item was clicked, including items added later.
  • Keep data in variables, change the data in handlers, then call your render function; never treat the page as the source of truth.

Further reading

Check your understanding

4 questions

Question 1 of 4

What is wrong with button.addEventListener("click", deleteExpense());?

Question 2 of 4

A list item contains <span>Coffee</span> <button class="delete">×</button>. The listener is on the <ul>. A user clicks the button. What are event.target and event.currentTarget?

Question 3 of 4

Why does a delegated listener keep working for expenses added after the page loaded?

Question 4 of 4

Inside a delegated click handler, why use event.target.closest(".delete") rather than checking event.target.classList.contains("delete")?

Practice

Code playground

Delete with one delegated listener

Each expense has a Delete button. The starter attaches a listener to each button, but renderExpenses replaces the buttons after the first delete, so the second delete does nothing, and new expenses can never be deleted.

Replace the loop at the bottom with one click listener on #expense-list. When a click lands on (or inside) a .delete button, remove that expense from expenses using the item’s data-id, then call renderExpenses(). Clicks elsewhere in the list must do nothing.

<h1>Pocket</h1>
<button type="button" id="add-sample">Add a sample expense</button>
<ul id="expense-list"></ul>
<p id="total"></p>
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • Deleting an expense removes it and updates the total
  • A second delete still works after the list re-rendered
  • Expenses added later can be deleted too
  • Clicking an item’s label deletes nothing
  • The expenses array itself is updated, not just the page