Event Delegation and Bubbling in JavaScript with Example

By Maulik Paghdal

14 Nov, 2025

Event Delegation and Bubbling in JavaScript with Example

Introduction

If you've ever attached event listeners to multiple elements and wondered if there's a smarter way to do it, you're in the right place. Event delegation is one of those concepts that clicks once you understand how event bubbling works, and suddenly your code becomes cleaner, more performant, and easier to maintain.

Let's break down what event bubbling is, how event delegation leverages it, and why you should care about both.

What is Event Bubbling?

When you trigger an event on an element (like clicking a button), that event doesn't just stay on that element. It bubbles up through the DOM tree, triggering the same event on each parent element all the way up to the document object.

Here's a simple example:

<div id="parent">
  <button id="child">Click me</button>
</div>
document.getElementById('parent').addEventListener('click', () => {
  console.log('Parent clicked');
});

document.getElementById('child').addEventListener('click', () => {
  console.log('Child clicked');
});

When you click the button, you'll see:

Child clicked
Parent clicked

The click event fired on the button first, then bubbled up to the parent div. This is event bubbling in action.

💡 Tip: Not all events bubble. Events like focus, blur, and load don't bubble by default. Use focusin and focusout if you need bubbling behavior for focus events.

The Three Phases of Event Propagation

Events actually go through three phases:

  1. Capturing Phase: Event travels down from the document to the target element
  2. Target Phase: Event reaches the target element itself
  3. Bubbling Phase: Event bubbles back up to the document

By default, event listeners trigger during the bubbling phase. You can opt into the capturing phase by passing true as the third argument to addEventListener:

element.addEventListener('click', handler, true); // Capturing phase
element.addEventListener('click', handler, false); // Bubbling phase (default)

Most of the time, you'll work with bubbling. Capturing is useful in specific cases where you need to intercept events before they reach their target.

What is Event Delegation?

Event delegation is a pattern where instead of attaching event listeners to multiple child elements, you attach a single listener to a parent element and let event bubbling do the work.

Here's the problem it solves. Say you have a list of items:

<ul id="item-list">
  <li>Item 1</li>
  <li>Item 2</li>
  <li>Item 3</li>
</ul>

Without delegation, you'd do this:

const items = document.querySelectorAll('#item-list li');
items.forEach(item => {
  item.addEventListener('click', (e) => {
    console.log('Clicked:', e.target.textContent);
  });
});

This works, but there are issues:

  • If you add new items dynamically, they won't have event listeners
  • You're creating multiple event listeners, which uses more memory
  • Removing items requires cleanup to avoid memory leaks

With event delegation:

document.getElementById('item-list').addEventListener('click', (e) => {
  if (e.target.tagName === 'LI') {
    console.log('Clicked:', e.target.textContent);
  }
});

Now you have one listener on the parent. When any <li> is clicked, the event bubbles up to the <ul>, and you check if the clicked element (e.target) is an <li>.

Benefits of Event Delegation

  • Better performance: One event listener instead of many
  • Dynamic content: Works with elements added after page load
  • Less memory: Fewer listeners means less memory usage
  • Easier cleanup: Remove one listener instead of tracking multiple ones

Real-World Example: Todo List

Let's build something practical. A todo list where you can mark items as complete or delete them.

<div id="app">
  <input type="text" id="todo-input" placeholder="Add a task">
  <button id="add-btn">Add</button>
  <ul id="todo-list"></ul>
</div>
const todoList = document.getElementById('todo-list');
const todoInput = document.getElementById('todo-input');
const addBtn = document.getElementById('add-btn');

// Add new todo
addBtn.addEventListener('click', () => {
  const text = todoInput.value.trim();
  if (!text) return;
  
  const li = document.createElement('li');
  li.innerHTML = `
    <span class="todo-text">${text}</span>
    <button class="delete-btn">Delete</button>
  `;
  todoList.appendChild(li);
  todoInput.value = '';
});

// Event delegation for all todo interactions
todoList.addEventListener('click', (e) => {
  // Toggle complete on todo text
  if (e.target.classList.contains('todo-text')) {
    e.target.classList.toggle('completed');
  }
  
  // Delete todo
  if (e.target.classList.contains('delete-btn')) {
    e.target.parentElement.remove();
  }
});
.completed {
  text-decoration: line-through;
  opacity: 0.6;
}

.delete-btn {
  margin-left: 10px;
  color: red;
  cursor: pointer;
}

With one event listener on the <ul>, we handle clicks on any todo item or delete button, even ones added dynamically. No need to attach listeners to each new todo.

Understanding event.target vs event.currentTarget

This trips people up. Here's the difference:

  • event.target: The element that triggered the event (where you actually clicked)
  • event.currentTarget: The element the listener is attached to
document.getElementById('parent').addEventListener('click', (e) => {
  console.log('Target:', e.target.id);           // child
  console.log('CurrentTarget:', e.currentTarget.id); // parent
});

When delegating events, you check e.target to see what was clicked, and use e.currentTarget to reference the parent element with the listener.

Stopping Event Propagation

Sometimes you want to prevent an event from bubbling. There are two methods:

element.addEventListener('click', (e) => {
  e.stopPropagation(); // Stops bubbling to parent elements
});
element.addEventListener('click', (e) => {
  e.stopImmediatePropagation(); // Stops bubbling AND prevents other listeners on this element
});

⚠️ Warning: Use these sparingly. Stopping propagation can break other parts of your code that rely on event bubbling, especially when working with third-party libraries or frameworks.

Preventing Default Behavior

This is different from stopping propagation. preventDefault() stops the browser's default action:

document.querySelector('a').addEventListener('click', (e) => {
  e.preventDefault(); // Link won't navigate
  console.log('Link clicked, but not following it');
});

Common use cases:

  • Preventing form submission to validate first
  • Stopping links from navigating
  • Customizing right-click context menus

Common Pitfalls and How to Avoid Them

1. Checking the Wrong Element

// Bad: might match nested elements you don't want
todoList.addEventListener('click', (e) => {
  if (e.target.tagName === 'LI') {
    // What if someone clicks a <span> inside the <li>?
  }
});

// Better: use closest() to find the nearest matching ancestor
todoList.addEventListener('click', (e) => {
  const li = e.target.closest('li');
  if (li && todoList.contains(li)) {
    // Now it works even if you click nested elements
  }
});

2. Forgetting to Check Parent Containment

// Make sure the clicked element is actually inside your delegated parent
if (li && todoList.contains(li)) {
  // Safe to proceed
}

3. Overusing Delegation

Not everything needs delegation. If you have a single button that won't be dynamically added or removed, just attach a listener directly:

// This is fine for a single, static element
document.getElementById('submit-btn').addEventListener('click', handleSubmit);

When NOT to Use Event Delegation

Event delegation isn't always the answer:

  • Performance-sensitive interactions: If you're tracking mouse movements or scroll events on many elements, delegation can actually hurt performance since every event bubbles up
  • Events that don't bubble: As mentioned earlier, some events like focus and blur don't bubble
  • Very specific element interactions: Sometimes direct listeners are clearer and simpler

Practical Tips

1. Use data attributes for cleaner delegation:

<button data-action="delete" data-id="123">Delete</button>
<button data-action="edit" data-id="123">Edit</button>
container.addEventListener('click', (e) => {
  const action = e.target.dataset.action;
  const id = e.target.dataset.id;
  
  if (action === 'delete') {
    deleteItem(id);
  } else if (action === 'edit') {
    editItem(id);
  }
});

2. Combine with closest() for nested structures:

table.addEventListener('click', (e) => {
  const row = e.target.closest('tr');
  if (row) {
    const id = row.dataset.id;
    console.log('Row clicked:', id);
  }
});

3. Namespace your delegated handlers:

// Keep related logic together
const todoHandlers = {
  toggleComplete(e) {
    if (e.target.classList.contains('todo-text')) {
      e.target.classList.toggle('completed');
    }
  },
  
  deleteTodo(e) {
    if (e.target.classList.contains('delete-btn')) {
      e.target.closest('li').remove();
    }
  }
};

todoList.addEventListener('click', (e) => {
  todoHandlers.toggleComplete(e);
  todoHandlers.deleteTodo(e);
});

Browser Support and Polyfills

Event bubbling and delegation work in all modern browsers and IE9+. The closest() method is supported in all modern browsers but needs a polyfill for IE.

📌 Note: If you're using a framework like React or Vue, they handle event delegation for you behind the scenes. React uses a synthetic event system with delegation at the root level, so you rarely need to think about it.

Wrapping Up

Event delegation might seem like a small optimization, but it makes a real difference in how you structure your code. Once you start thinking in terms of delegated events, you'll find yourself writing less code that does more.

The key takeaways:

  • Events bubble up through the DOM by default
  • Event delegation uses bubbling to handle events on a parent instead of many children
  • Use e.target to identify what was clicked and e.currentTarget for the delegating element
  • closest() is your friend for finding the right element in nested structures
  • Not everything needs delegation, use it where it makes sense

Next time you're about to loop through elements and attach listeners, ask yourself: can I delegate this instead? Most of the time, the answer is yes.

Topics Covered

About Author

Maulik Paghdal

I'm Maulik Paghdal, the founder of Script Binary and a passionate full-stack web developer. I have a strong foundation in both frontend and backend development, specializing in building dynamic, responsive web applications using Laravel, Vue.js, and React.js. With expertise in Tailwind CSS and Bootstrap, I focus on creating clean, efficient, and scalable solutions that enhance user experiences and optimize performance.