Skillversitas

Event Bubbling & Capturing

When an event occurs on an element, that event passes through three phases: capturing (down from the root to the target), target (on the element itself), and bubbling (up from the target to the root). Understanding this flow is important for controlling how events propagate through the DOM.

Event Flow

        Document
       /        \
   Capturing    Bubbling
      ↓            ↑
    html → body → div.container → button#btn
                                 (target)
  1. Capturing Phase — the event travels down from the root to the target (parent → child)
  2. Target Phase — the event on the target element itself
  3. Bubbling Phase — the event travels up from the target to the root (child → parent)

By default, listeners are called during the bubbling phase. To catch the capturing phase, pass { capture: true }.

Code Example

Bubbling

<div id="container">
  <button id="btn">Click Me</button>
</div>
const container = document.getElementById('container');
const btn = document.getElementById('btn');

// Default: bubbling phase
container.addEventListener('click', function () {
  console.log('Container clicked (bubbling)');
});

btn.addEventListener('click', function () {
  console.log('Button clicked');
});

// Click the button → output:
// "Button clicked"
// "Container clicked (bubbling)"
// Because the event bubbles up from the button to the container

Capturing

// Use { capture: true } to catch the downward phase
container.addEventListener('click', function () {
  console.log('Container (capturing)'); // called FIRST
}, true);

btn.addEventListener('click', function () {
  console.log('Button (target)');
});

// Click the button → output:
// "Container (capturing)" — called first because it captures downward
// "Button (target)"

Complete Capturing + Bubbling Order

// Capturing (downward order)
document.getElementById('outer').addEventListener('click', function () {
  console.log('1. Outer capturing');
}, true);

document.getElementById('inner').addEventListener('click', function () {
  console.log('2. Inner capturing');
}, true);

// Target
document.getElementById('target').addEventListener('click', function () {
  console.log('3. Target');
});

// Bubbling (upward order)
document.getElementById('inner').addEventListener('click', function () {
  console.log('4. Inner bubbling');
}, false);

document.getElementById('outer').addEventListener('click', function () {
  console.log('5. Outer bubbling');
}, false);

// Output:
// 1. Outer capturing
// 2. Inner capturing
// 3. Target
// 4. Inner bubbling
// 5. Outer bubbling

Code Explanation

event.stopPropagation()

Prevents the event from continuing to the next phase (it won't bubble up to the parent or down to a deeper child).

// A button inside a card
card.addEventListener('click', function (e) {
  console.log('Card clicked');
});

deleteButton.addEventListener('click', function (e) {
  e.stopPropagation(); // STOP — the event doesn't reach the card
  console.log('Delete clicked, card not affected');
});

event.stopImmediatePropagation()

More aggressive than stopPropagation(). It stops propagation AND prevents other listeners on the same element from being called.

element.addEventListener('click', function (e) {
  e.stopImmediatePropagation();
  console.log('Handler 1');
});

element.addEventListener('click', function (e) {
  console.log('Handler 2 — NEVER called');
});

// Click the element → only "Handler 1" appears

event.stopPropagation() vs event.preventDefault()

// stopPropagation: stop the event from propagating to the parent/child
// preventDefault: stop the browser's default action

link.addEventListener('click', function (e) {
  e.preventDefault();   // does NOT navigate to the URL
  e.stopPropagation();  // the event doesn't bubble up to the parent
  console.log('Link clicked, but page does not navigate');
});

Real-World Examples

Nested Modals in an Academic Portal

Academic portals often have multi-level modals — for example, a "Select Course" modal containing a "Course Details" modal inside it. A click inside the details modal must not close the outer modal.

// Outer modal (Select Course)
const modalLuar = document.querySelector('#modal-select-course');

// Inner modal (Course Details)
const modalDalam = document.querySelector('#modal-detail-mk');

// Click the outer modal's overlay → close the outer modal
modalLuar.querySelector('.modal-overlay').addEventListener('click', function (e) {
  // If what was clicked is the inner modal, do NOT close the outer modal
  if (modalDalam.contains(e.target)) return;

  modalLuar.classList.remove('active');
});

// Click inside the details modal → don't propagate to the outer modal
modalDalam.addEventListener('click', function (e) {
  e.stopPropagation();
  // The outer modal stays open
});

// Close button for the inner modal
modalDalam.querySelector('.btn-close').addEventListener('click', function () {
  modalDalam.classList.remove('active');
});

// Close button for the outer modal
modalLuar.querySelector('.btn-close').addEventListener('click', function () {
  modalLuar.classList.remove('active');
});

A Form Inside a Dropdown

// A dropdown menu in the campus navbar
const dropdown = document.querySelector('.dropdown-menu');

// A click inside the dropdown shouldn't close the dropdown
dropdown.addEventListener('click', function (e) {
  e.stopPropagation();
});

// A click outside the dropdown → close it
document.addEventListener('click', function () {
  dropdown.classList.remove('show');
});

Stopping Bubbling on Buttons in a List

// A student list, each item has a checkbox and a button
const listContainer = document.querySelector('#list-student');

// Click an item → select it for details
listContainer.addEventListener('click', function (e) {
  const item = e.target.closest('.item-student');
  if (!item) return;
  showDetails(item.dataset.studentId);
});

// A checkbox click shouldn't trigger the selection
listContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => {
  cb.addEventListener('click', function (e) {
    e.stopPropagation(); // prevents the event from bubbling up to the item
  });
});

// An edit button click shouldn't trigger the selection
listContainer.querySelectorAll('.btn-edit').forEach(btn => {
  btn.addEventListener('click', function (e) {
    e.stopPropagation();
    const studentId = e.target.closest('.item-student').dataset.studentId;
    openEditForm(studentId);
  });
});

Setting { once: true }

// The listener is called only once and then removed automatically
submitButton.addEventListener('click', function () {
  submitForm();
}, { once: true }); // Useful for preventing double-click submits

Using event.target vs event.currentTarget

listContainer.addEventListener('click', function (e) {
  // event.target = the element clicked directly (could be a button, icon, or text)
  // event.currentTarget = the element where the listener is attached (#list-student)

  // Use closest() to find the item from the target
  const item = e.target.closest('.item-student');
  if (!item) return;

  console.log('Clicked from:', e.target);         // click details
  console.log('Item found:', item);           // the correct row
  console.log('Listener di:', e.currentTarget);   // the main container
});

Conclusion

Event bubbling makes events travel up to the parent; capturing makes events travel down to the child. Use stopPropagation() to prevent an event from propagating further out, and stopImmediatePropagation() to also stop other listeners on the same element. For multi-level modals or nested components, always consider event propagation so that inner interactions don't disturb outer components.