Skip to content
intermediate

Event Delegation in JavaScript with a Clickable List

You built a list. You wired every item with a click listener. It worked. Then you added one new item, clicked it, and nothing happened. The old approach…

Published 2026-09-06Updated 2026-09-129 min read
Close-up of a person coding on a laptop, showcasing web development and programming concepts.
Close-up of a person coding on a laptop, showcasing web development and programming concepts. Photo by Lukas Blazek on Pexels.

You built a list. You wired every item with a click listener. It worked. Then you added one new item, clicked it, and nothing happened. The old approach works until the list grows. The fix is to stop wiring every item and let one parent listener do the work.

Why Clicking a New List Item Does Nothing

Here's the naive approach that most beginners reach for first. You have a list, you grab all the items, and you attach a click listener to each one:

<ul id="todo-list">
  <li>Buy coffee</li>
  <li>Write code</li>
  <li>Take a walk</li>
</ul>
const items = document.querySelectorAll("#todo-list li");

items.forEach(function (item) {
  item.addEventListener("click", function () {
    item.classList.toggle("done");
  });
});

This works for the three items that exist at page load. Click any of them and the done class toggles on and off.

Now add a fourth item:

const list = document.getElementById("todo-list");
const newItem = document.createElement("li");
newItem.textContent = "Drink water";
list.appendChild(newItem);

Click "Drink water." Nothing happens. No error, no class toggle, just silence.

The reason is simple: querySelectorAll only returns the elements that exist when it runs. Your loop attached listeners to those three original items. The fourth item was born without a listener, and JavaScript does not magically re-run your loop when the DOM changes.

This is the core constraint: listeners are attached to elements that exist at the time of the loop, not to future elements.

The goal is to write one listener that works for every current and future item. That's what JavaScript event delegation gives you.

What Event Bubbling Actually Does

Event delegation depends on a browser behavior called event bubbling. Here's what happens when you click an element on a page:

  1. The event fires on the element you actually clicked.
  2. Then it fires on that element's parent.
  3. Then on the parent's parent.
  4. And so on, all the way up to the top of the document.

The click does not just happen in one spot. It travels upward through the family tree, and every ancestor gets a chance to hear it.

Let's prove it with a tiny example:

<div id="outer">
  <div id="inner">
    <button id="button">Click me</button>
  </div>
</div>
const outer = document.getElementById("outer");
const inner = document.getElementById("inner");
const button = document.getElementById("button");

button.addEventListener("click", function () {
  console.log("button clicked");
});

inner.addEventListener("click", function () {
  console.log("inner clicked");
});

outer.addEventListener("click", function () {
  console.log("outer clicked");
});

Click the button and check the console:

button clicked
inner clicked
outer clicked

The event starts at the button, then bubbles up through the inner div, then reaches the outer div. Each listener fires in turn as the event passes through.

This is why a parent listener can catch clicks meant for its children. The click on a list item bubbles up to the list itself, so a listener on the list hears it.

Knowledge check

Check your understanding

Answer this question before you continue.

When the button in the article's nested div example is clicked, what order is logged?
Output Prediction

Focus: Predict the order in which bubbling click listeners run from the clicked element through its ancestors.

The listeners are attached to the button, then the inner div, then the outer div, and each logs its own label.

event.target vs. event.currentTarget

When your parent listener fires, you need to know which element the user actually clicked. Two properties on the event object answer that question, and confusing them is one of the most common delegation bugs.

event.target is the innermost element the user actually clicked. If the user clicks directly on an <li>, that <li> is the target.

event.currentTarget is the element whose listener is running. In a delegated listener attached to a <ul>, event.currentTarget is always that <ul>, no matter what was clicked.

Here's the difference in action:

const list = document.getElementById("todo-list");

list.addEventListener("click", function (event) {
  console.log("target:", event.target.tagName);
  console.log("currentTarget:", event.currentTarget.tagName);
});

Click on the first list item and you'll see:

target: LI
currentTarget: UL

The target is the item you clicked. The currentTarget is the list that owns the listener.

Delegation code must read event.target because that tells you which child was actually clicked. If you check event.currentTarget, you'll always see the parent, which tells you nothing about the specific item.

Knowledge check

Check your understanding

Answer this question before you continue.

A click on an <li> is handled by a listener attached to the <ul>. Which pairing correctly identifies the two event properties?
Single Choice

Focus: Distinguish the clicked element in event.target from the listener-owning element in event.currentTarget.

Building One Delegated Listener

Flowchart showing a click on a list item or its nested content bubbling to one ul listener, which finds the nearest li with closest and toggles its done state; clicks outside an li exit without action.
One parent listener handles clicks from current and newly added list items by finding the nearest matching li.

Now let's build the real pattern. Instead of looping over list items, attach one click listener to the <ul>:

const list = document.getElementById("todo-list");

list.addEventListener("click", function (event) {
  if (event.target.tagName === "LI") {
    event.target.classList.toggle("done");
  }
});

One listener. No loop. No per-item wiring.

Inside the handler, you check whether event.target is the kind of element you care about. If the user clicked an <li>, toggle its class. If they clicked empty space inside the list, the target will be the <ul> itself, and the check fails quietly.

But there's a wrinkle. What if your list items contain nested content?

<ul id="todo-list">
  <li>
    <span>Buy coffee</span>
    <button class="delete">×</button>
  </li>
</ul>

If the user clicks the <span> or the <button>, event.target is no longer the <li>. It's the span or the button. Your tagName === "LI" check fails, and the click does nothing.

The fix is the closest() method. It walks up the ancestor chain from event.target and returns the nearest matching element, including the element you call it on:

list.addEventListener("click", function (event) {
  const item = event.target.closest("li");

  if (!item) {
    return;
  }

  item.classList.toggle("done");
});

If the user clicks the span, the button, or the li itself, closest("li") finds the containing list item. If they click empty space in the list, closest("li") returns null, and you exit early.

This is the safe, complete version of the delegated pattern:

  1. Attach one listener to the parent.
  2. Use event.target.closest() to find the matching ancestor.
  3. Act only when a real item was found.

Knowledge check

Check your understanding

Answer this question before you continue.

A delegated handler should toggle the containing <li> when the user clicks either its text span or a button inside it. Which replacement fixes the handler?
Debugging

Focus: Use closest() in a delegated handler so clicks on nested content resolve to their containing list item.

Current code: list.addEventListener("click", function (event) { if (event.target.tagName === "LI") { event.target.classList.toggle("done"); } });

Why Delegation Wins as the List Grows

Here's the payoff. Add new items to the list and they respond immediately, with zero extra code:

const addButton = document.getElementById("add-button");
const input = document.getElementById("new-item");
const list = document.getElementById("todo-list");

addButton.addEventListener("click", function () {
  const newItem = document.createElement("li");
  newItem.textContent = input.value;
  list.appendChild(newItem);
  input.value = "";
});

The delegated listener on the list was attached once, before any of these new items existed. When a new item is clicked, the event bubbles up to the list, and the listener handles it exactly like the original items.

Compare the two approaches:

ApproachListener countNew itemsCode to maintain
Loop over itemsOne per itemNeed re-wiringRe-run loop after every change
Delegated listenerOne totalWork automaticallyNone

With the loop approach, you need N listeners for N items, and every time you add or remove an item, you must re-run the wiring. With delegation, you have one listener for any number of items, and DOM changes need no listener management at all.

The performance benefit is real but honest: with three items, the difference is negligible. It matters when you have hundreds of rows, or when items change frequently and re-wiring would create a maintenance headache.

Knowledge check

Check your understanding

Answer this question before you continue.

Why does a newly appended <li> respond to clicks when the delegated listener was attached before the <li> existed?
Misconception Check

Focus: Explain why one listener on a list handles both existing and dynamically added list items.

Common Mistakes to Avoid

Delegation is a small pattern, but beginners break it in predictable ways. Watch for these four.

Checking the wrong element. If you test event.target against the parent instead of the clicked child, your handler never matches. Remember: the target is the innermost clicked element, not the element that owns the listener.

Forgetting about nested content. A button inside an <li> changes event.target. If your handler checks event.target.tagName === "LI", clicks on the button will fail. Use closest() to handle nested content safely.

Assuming every event bubbles. Delegation only works for events that bubble. Some events do not bubble: focus, blur, scroll, mouseenter, and mouseleave are common examples. You cannot delegate those with a parent listener. For focus and blur, you can use the capturing phase instead, but that's a more advanced topic.

Using stopPropagation() in a child handler. If you have a listener on a child element that calls event.stopPropagation(), it stops the event from traveling upward. Your delegated parent listener never fires. This is a silent killer: no error, just a listener that mysteriously stops working.

Warning: If a delegated listener stops working after you add another listener somewhere else, look for stopPropagation() first. It's the usual suspect.

When to Use Delegation and When Not To

Delegation is a tool, not a rule. Here's when it earns its keep.

Use delegation when:

  • Items are added or removed dynamically.
  • You have many similar elements that all need the same behavior.
  • You're building a list, table, or menu that can grow.

Skip it when:

  • You have a single fixed button.
  • You have a small set of elements that never change after page load.

For one button that exists from the start, a direct listener is simpler and clearer:

const saveButton = document.getElementById("save-button");
saveButton.addEventListener("click", saveData);

My rule of thumb: delegate when the set of clickable elements can change or get large. If neither is true, attach the listener directly and move on.

Practice: Make a Growing To-Do List Respond to Clicks

Time to build it yourself. Start with this HTML:

<ul id="todo-list">
  <li>Buy coffee</li>
  <li>Write code</li>
</ul>

<input id="new-item" placeholder="Add a task" />
<button id="add-button">Add</button>

Your task: add one delegated click listener to the <ul> that marks a clicked item as done by toggling a class. Then add the logic to create new items from the input.

Expected behavior: Newly added items respond to clicks immediately, with no extra wiring. You never attach a listener to a new item.

Stretch goal: Add a delete button inside each item, then use closest() in the same delegated listener so the delete button works without its own listener. If you get stuck, trace what event.target is when the user clicks the button versus the item text.

The Durable Rule

Stop wiring every item. Let one parent listener catch the clicks that bubble up from its children. That single listener handles every item that exists now and every item you add later.

Event delegation is one of those patterns that feels like a trick until you see the mechanism, then it feels like the only sensible way to build. Once you're comfortable with it, the natural next step is combining delegation with form handling, or using it to build a small interactive component where the DOM changes as the user works.

Knowledge check

Final check

Finish the article by checking the ideas you just learned.

Which event from the article is identified as one that does not bubble and therefore cannot be handled with an ordinary delegated parent listener?
Question 1 of 2Single Choice

Focus: Recognize that delegation requires an event that bubbles to the parent listener.

Which situation best matches the article's rule of thumb for using event delegation?
Question 2 of 2Misconception Check

Focus: Choose delegation when a set of similar clickable elements can grow or change.

References

  1. Event bubbling - Learn web development | MDNdeveloper.mozilla.org
  2. Event delegationjavascript.info
8sources checked
8source domains
5searches run

Research updated Sep 6, 2026

Keep learning

Related tutorials

Continue with nearby JavaScript topics and beginner-friendly explanations.

A breathtaking view of a tropical sunset with vibrant colors reflecting on the calm sea.
beginner
7 min read

Arrow Functions Basics

JavaScript arrow functions let you write the same functions you already know in fewer lines. The idea isn't new—only the syntax is shorter.

Read tutorial