Skip to content
aviral gupta

// B6.4 · ~32 min · Beginner

Bubbling and event delegation

After this lesson you know the path a click takes through the page, tell target from currentTarget, and let one listener on a list handle every item, even items added later.

Lesson 4 of 5 in B6 The DOM and events

You will be able to

  • Trace an event through the capture, target and bubble phases and predict the order in which listeners on ancestors run
  • Tell event.target from event.currentTarget, and use stopPropagation() only for a real conflict
  • Handle many elements, including ones added later, with one delegated listener using closest() and contains()
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B6.3. What does this print?

    const button = document.querySelector('#like');
    let n = 0;
    function count() {
      n += 1;
    }
    button.addEventListener('click', count);
    button.click();
    button.removeEventListener('click', count);
    button.click();
    console.log(n);
  2. Predict · Activity 2 of 7

    Predict before you read on. The listener is on the list, and the click lands on the item inside it. What does this print?

    const list = document.querySelector('#list');
    list.addEventListener('click', (event) => {
      console.log(event.target.tagName, event.currentTarget.tagName);
    });
    document.querySelector('#first').click();
  3. Practice · Activity 3 of 7

    The click landed on a <b> inside a <span> inside the li. Fill in the method that walks up from the clicked element to the nearest li.

    const item = event.target.____('li');
    const item = event.target.('li');
  4. Practice · Activity 4 of 7

    Match each part of an event's journey to what happens in it.

  5. Practice · Activity 5 of 7

    The button #go is inside the div #box. What does this print?

    const box = document.querySelector('#box');
    const go = document.querySelector('#go');
    const order = [];
    box.addEventListener('click', () => order.push('box bubble'));
    box.addEventListener('click', () => order.push('box capture'), { capture: true });
    go.addEventListener('click', () => order.push('button'));
    go.click();
    console.log(order.join(', '));
  6. Brain teaser · Activity 6 of 7

    Brain teaser. Every item gets a listener, then a new item is added and both are clicked. What does this print?

    const list = document.querySelector('#list');
    let n = 0;
    for (const li of list.querySelectorAll('li')) {
      li.addEventListener('click', () => {
        n += 1;
      });
    }
    const coffee = document.createElement('li');
    coffee.textContent = 'Coffee';
    list.append(coffee);
    coffee.click();
    list.querySelector('li').click();
    console.log(n);
  7. Apply · Activity 7 of 7

    Mini-task: removable tags. Write an index.html with an empty list #tags and a span #count. In main.js keep the tags in an array, ['js', 'dom', 'events'], and write render(): each tag becomes an li with its text and a small button with data-index and an aria-label such as "Remove js"; #count shows how many tags there are. Add one click listener to the list, not one per button: find the button with closest, check it is inside the list, remove that tag from the array and call render() again. Try it through a local web server.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

One listener for a whole to-do list

The page has an empty list. main.js keeps the tasks in an array and builds the items with render(), as in lesson B6.2. One click listener on the list handles every Done button: it finds the button with closest, checks it belongs to the list, flips that task and renders again. Two listeners on body show the order of the phases. A third task is added after the listener, and the script then clicks its Done button and the list itself, the way the exercise tests do.

main.js

const list = document.querySelector('#list');
const tasks = [
  { text: 'Water the plants', done: false },
  { text: 'Buy bread', done: false }
];

function render() {
  const items = tasks.map((task, index) => {
    const li = document.createElement('li');
    li.dataset.index = String(index);
    if (task.done) li.classList.add('done');
    const text = document.createElement('span');
    text.textContent = task.text;
    const button = document.createElement('button');
    button.textContent = 'Done';
    li.append(text, ' ', button);
    return li;
  });
  list.replaceChildren(...items);
}

// One listener on the list handles every button, now and later.
list.addEventListener('click', (event) => {
  const button = event.target.closest('button');
  if (!button || !list.contains(button)) return; // not a button of this list
  const index = Number(button.closest('li').dataset.index);
  tasks[index].done = !tasks[index].done;
  console.log('toggled', index, '| target:', event.target.tagName, '| currentTarget:', event.currentTarget.id);
  render();
});

// Two listeners on body show the order: capture first, bubble last.
document.body.addEventListener('click', () => console.log('body, capture phase'), { capture: true });
document.body.addEventListener('click', () => console.log('body, bubble phase'));

render();
tasks.push({ text: 'Call Mum', done: false }); // added after the listener
render();

// The script clicks its own page, the way the exercise tests do.
list.querySelector('[data-index="2"] button').click();
list.click(); // the list itself, not a button: ignored
console.log('items:', list.children.length);

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Bubbling</title>
</head>
<body>
  <main>
    <h1>Today</h1>
    <ul id="list"></ul>
  </main>
  <script type="module" src="main.js"></script>
</body>
</html>

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Output

body, capture phase
toggled 2 | target: BUTTON | currentTarget: list
body, bubble phase
body, capture phase
body, bubble phase
items: 3
<!-- #list -->
<ul id="list"><li data-index="0"><span>Water the plants</span> <button>Done</button></li><li data-index="1"><span>Buy bread</span> <button>Done</button></li><li data-index="2" class="done"><span>Call Mum</span> <button>Done</button></li></ul>
  • The body capture listener ran first, on the way down; the list's listener ran as the click bubbled up; the plain body listener ran last.
  • Call Mum was added after the listener and still works: the listener sits on the list, not on the items.
  • target is the button that was clicked, currentTarget the list whose listener ran.
  • The click on the list itself passed through body as well, but closest found no button, so the list's listener returned early.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Exercises

Exercise 1 of 2

One listener for a colour palette

Replace the starter's three listeners with one click listener on #palette. A click on a colour button, or on the dot inside it, shows "Chosen: " plus the button's data-color in #chosen, and gives that button, and only that one, the class active. A click on the palette between the buttons changes nothing. A button added to the palette later must work too. The starter reads event.target.dataset.color, which is undefined when the click lands on the dot, and new buttons get no listener.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run the tests: the dot gives "Chosen: undefined", nothing gets the class active, and the Gold button does nothing.

  2. Hint 2

    One listener: palette.addEventListener('click', (event) => { … }). Inside, const button = event.target.closest('button'), and return when !button || !palette.contains(button).

  3. Hint 3

    Remove active from every button in a loop first, then add it to the one clicked, and read button.dataset.color, not event.target.dataset.color.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

const palette = document.querySelector('#palette');
const chosen = document.querySelector('#chosen');

palette.addEventListener('click', (event) => {
  const button = event.target.closest('button');
  if (!button || !palette.contains(button)) return;
  for (const other of palette.querySelectorAll('button')) {
    other.classList.remove('active');
  }
  button.classList.add('active');
  chosen.textContent = 'Chosen: ' + button.dataset.color;
});
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

const palette = document.querySelector('#palette');
const chosen = document.querySelector('#chosen');

// TODO: replace these listeners with one listener on the palette.
for (const button of palette.querySelectorAll('button')) {
  button.addEventListener('click', (event) => {
    chosen.textContent = 'Chosen: ' + event.target.dataset.color;
  });
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const button = (page, color) => page.document.querySelector('#palette [data-color="' + color + '"]');
const chosen = (page) => page.document.querySelector('#chosen').textContent;
const active = (page) => [...page.document.querySelectorAll('#palette .active')].map((b) => b.dataset.color);

test('Clicking a button shows its colour', async () => {
  const page = await loadPage();
  button(page, 'red').click();
  assert.equal(chosen(page), 'Chosen: red', 'after clicking Red the page shows ' + JSON.stringify(chosen(page)));
});

test('A click on the dot inside a button counts for that button', async () => {
  const page = await loadPage();
  button(page, 'green').querySelector('.dot').click();
  assert.equal(chosen(page), 'Chosen: green', 'after clicking the dot in Green the page shows ' + JSON.stringify(chosen(page)));
});

test('Only the clicked button has the class active', async () => {
  const page = await loadPage();
  button(page, 'red').click();
  button(page, 'blue').click();
  assert.deepEqual(active(page), ['blue'], 'the buttons with the class active are ' + JSON.stringify(active(page)));
});

test('A click on the palette itself changes nothing', async () => {
  const page = await loadPage();
  page.document.querySelector('#palette').click();
  assert.equal(chosen(page), 'No colour yet', 'after a click between the buttons the page shows ' + JSON.stringify(chosen(page)));
  assert.deepEqual(active(page), [], 'the buttons with the class active are ' + JSON.stringify(active(page)));
});

test('A button added later works too', async () => {
  const page = await loadPage();
  const extra = page.document.createElement('button');
  extra.type = 'button';
  extra.dataset.color = 'gold';
  extra.textContent = 'Gold';
  page.document.querySelector('#palette').append(extra);
  extra.click();
  assert.equal(chosen(page), 'Chosen: gold', 'after clicking the new Gold button the page shows ' + JSON.stringify(chosen(page)));
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Palette</title>
</head>
<body>
  <div id="palette">
    <button type="button" data-color="red"><span class="dot"></span> Red</button>
    <button type="button" data-color="green"><span class="dot"></span> Green</button>
    <button type="button" data-color="blue"><span class="dot"></span> Blue</button>
  </div>
  <p id="chosen">No colour yet</p>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Run the checks (needs learnrun.js in the same folder):

npm install jsdom@28.1.0
node --test
Download learnrun.js

Exercise 2 of 2

A checklist with Remove and Done

The checklist sits inside the section #today, which counts every click inside it; keep that counter working. Write one click listener on #list: a click on an item's Remove button removes that item from items; a click anywhere else on an item toggles its done; a click on the list between the items changes nothing. Call render() after each change. The starter stops propagation, so the counter never moves, removes the item on every click, and on a click between the items closest('li') climbs past the list to #today and removes the first item.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run the tests: the counter stays at 0, a click on a text removes the item, and a click between the items removes the first one.

  2. Hint 2

    Delete event.stopPropagation(). Then const li = event.target.closest('li'), and return when !li || !list.contains(li): the list sits inside another li, #today.

  3. Hint 3

    Use event.target.closest('.remove') to tell a Remove click from any other click on the item, change items, and call render().

Show a solution

One way to solve it. Yours can look different and still pass the checks.

const list = document.querySelector('#list');
const items = [
  { text: 'Water the plants', done: false },
  { text: 'Buy bread', done: false },
  { text: 'Call Mum', done: false }
];

function render() {
  const lis = items.map((item, index) => {
    const li = document.createElement('li');
    li.dataset.index = String(index);
    if (item.done) li.classList.add('done');
    const text = document.createElement('span');
    text.textContent = item.text;
    const remove = document.createElement('button');
    remove.type = 'button';
    remove.className = 'remove';
    remove.textContent = 'Remove';
    li.append(text, ' ', remove);
    return li;
  });
  list.replaceChildren(...lis);
}

// Given: counts every click inside the Today section. Keep it working.
let clicks = 0;
document.querySelector('#today').addEventListener('click', () => {
  clicks += 1;
  document.querySelector('#clicks').textContent = String(clicks);
});

list.addEventListener('click', (event) => {
  const li = event.target.closest('li');
  if (!li || !list.contains(li)) return;
  const index = Number(li.dataset.index);
  if (event.target.closest('.remove')) {
    items.splice(index, 1);
  } else {
    items[index].done = !items[index].done;
  }
  render();
});

render();
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

const list = document.querySelector('#list');
const items = [
  { text: 'Water the plants', done: false },
  { text: 'Buy bread', done: false },
  { text: 'Call Mum', done: false }
];

function render() {
  const lis = items.map((item, index) => {
    const li = document.createElement('li');
    li.dataset.index = String(index);
    if (item.done) li.classList.add('done');
    const text = document.createElement('span');
    text.textContent = item.text;
    const remove = document.createElement('button');
    remove.type = 'button';
    remove.className = 'remove';
    remove.textContent = 'Remove';
    li.append(text, ' ', remove);
    return li;
  });
  list.replaceChildren(...lis);
}

// Given: counts every click inside the Today section. Keep it working.
let clicks = 0;
document.querySelector('#today').addEventListener('click', () => {
  clicks += 1;
  document.querySelector('#clicks').textContent = String(clicks);
});

// TODO: Remove removes its item; a click elsewhere on an item toggles done.
list.addEventListener('click', (event) => {
  event.stopPropagation(); // keep the click to ourselves
  const li = event.target.closest('li');
  items.splice(Number(li.dataset.index), 1);
  render();
});

render();

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const item = (page, n) => page.document.querySelectorAll('#list li')[n];
const texts = (page) => [...page.document.querySelectorAll('#list li span')].map((span) => span.textContent);
const done = (page) => [...page.document.querySelectorAll('#list li')].map((li) => li.classList.contains('done'));

test('Remove takes out its item', async () => {
  const page = await loadPage();
  item(page, 1).querySelector('.remove').click();
  assert.deepEqual(texts(page), ['Water the plants', 'Call Mum'], 'after removing Buy bread the list shows ' + JSON.stringify(texts(page)));
});

test('A click on the text marks the item done', async () => {
  const page = await loadPage();
  item(page, 0).querySelector('span').click();
  assert.deepEqual(texts(page), ['Water the plants', 'Buy bread', 'Call Mum'], 'after clicking a text the list shows ' + JSON.stringify(texts(page)));
  assert.deepEqual(done(page), [true, false, false], 'after clicking the first text, done is ' + JSON.stringify(done(page)));
});

test('A second click on the text marks it not done', async () => {
  const page = await loadPage();
  item(page, 2).querySelector('span').click();
  item(page, 2).querySelector('span').click();
  assert.deepEqual(done(page), [false, false, false], 'after two clicks on the last text, done is ' + JSON.stringify(done(page)));
});

test('A click on the list itself changes nothing', async () => {
  const page = await loadPage();
  page.document.querySelector('#list').click();
  assert.deepEqual(texts(page), ['Water the plants', 'Buy bread', 'Call Mum'], 'after a click between the items the list shows ' + JSON.stringify(texts(page)));
  const today = page.document.querySelector('#today').classList.contains('done');
  assert.equal(today, false, 'the section Today got the class done');
});

test('Removing works again after the list is rebuilt', async () => {
  const page = await loadPage();
  item(page, 0).querySelector('.remove').click();
  item(page, 0).querySelector('.remove').click();
  assert.deepEqual(texts(page), ['Call Mum'], 'after removing the first item twice the list shows ' + JSON.stringify(texts(page)));
});

test('Clicks in the list still reach the section counter', async () => {
  const page = await loadPage();
  item(page, 0).querySelector('span').click();
  item(page, 1).querySelector('.remove').click();
  const clicks = page.document.querySelector('#clicks').textContent;
  assert.equal(clicks, '2', 'after two clicks in the list the counter shows ' + JSON.stringify(clicks));
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Checklist</title>
</head>
<body>
  <ul id="sections">
    <li id="today">Today
      <ul id="list"></ul>
    </li>
  </ul>
  <p>Clicks in Today: <span id="clicks">0</span></p>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Run the checks (needs learnrun.js in the same folder):

npm install jsdom@28.1.0
node --test
Download learnrun.js

Common mistakes

Using closest() without checking for null

const list = document.querySelector('#list');
list.addEventListener('click', (event) => {
  const button = event.target.closest('button');
  console.log('remove', button.closest('li').dataset.index);
});
// A click on the list itself, between the items:
list.click();

What Node.js prints

TypeError: Cannot read properties of null (reading 'closest')

Why, and the fix

A delegated listener runs for every click inside the container, not only for clicks on buttons. When the click lands between the items, closest('button') finds no button and returns null. Check first and return early: if (!button || !list.contains(button)) return;. Firefox and Safari word this message differently.

Passing a selector to contains()

const list = document.querySelector('#list');
list.addEventListener('click', (event) => {
  const button = event.target.closest('button');
  if (!button || !list.contains('button')) return;
  console.log('remove', button.closest('li').dataset.index);
});
list.querySelector('button').click();

What Node.js prints

TypeError: Failed to execute 'contains' on 'Node': parameter 1 is not of type 'Node'.

Why, and the fix

closest takes a CSS selector, but contains takes a node: the element you found. Write list.contains(button), without quotes. It returns true when button is the list itself or anywhere inside it.

Calling closest() on the event instead of its target

const list = document.querySelector('#list');
list.addEventListener('click', (event) => {
  const item = event.closest('li');
  console.log('clicked', item.dataset.index);
});
list.querySelector('button').click();

What Node.js prints

TypeError: event.closest is not a function

Why, and the fix

The event object describes what happened; it is not an element, so it has no closest. Start from the element where the click happened: event.target.closest('li'). Firefox and Safari word this message differently.

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

An event travels: capture, target, bubble

A click does not happen to one element alone. The browser sends the event along a path through the tree, in three phases. Capture: from window down through each ancestor to the parent of the clicked element. Target: at the element itself. Bubble: back up through the parent, its parent, and on to document and window. addEventListener listens in the bubble phase by default, so a listener on a list also runs when an item inside it is clicked. With { capture: true } it listens on the way down instead and runs before the target's own listeners. An event you build with new Event() does not bubble unless you pass { bubbles: true }.

target, currentTarget, and when to stop

event.target is the element the event started on, the innermost one clicked; it stays the same along the whole path. event.currentTarget is the element whose listener is running now, so it changes as the event bubbles: the button, then the list, then body. event.stopPropagation() ends the journey: listeners further along the path no longer run. It does not cancel the default action; that is preventDefault(). Use it rarely, for a real conflict, such as a click inside a box that must not also close the box. As a habit it breaks code you cannot see, such as a menu that closes on any click on the page, or a click counter higher up.

Event delegation

Instead of one listener per item, put one listener on the container and let the clicks bubble up to it. Inside, find the item from event.target: event.target.closest("li") starts at the clicked element itself and walks up to the nearest match, or gives null. The click may land on a span inside the li, so closest is safer than looking at event.target alone. Then check list.contains(li): closest can climb past the container and find an li outside it. Return early when there is no match. Delegation also covers elements added later, or rebuilt by render(), because the listener sits on the container, which stays. Keep each item's data in data-* attributes.

Sources

Last reviewed October 4, 2026