TL;DR

Mỗi event trong DOM đi qua 3 phase: capturing (root → target), target, và bubbling (target → root). Mặc định handler chạy ở bubbling. Hiểu đúng flow này giúp bạn dùng được event delegation — một pattern cho phép chỉ bind 1 listener cho cả trăm child element. stopPropagation(), stopImmediatePropagation(), và preventDefault() là 3 method khác nhau cho 3 mục đích khác nhau — dùng sai sẽ tạo ra "dead zone" phá analytics và modal.

Event propagation là gì?

Khi bạn click vào một <button> nằm trong <div> trong <form>, browser không chỉ fire event ở mỗi cái button. Event đi qua toàn bộ chuỗi ancestor theo thứ tự quy định bởi DOM Events spec. Đó chính là event propagation.

Spec định nghĩa 3 phase rõ ràng, tương ứng với giá trị event.eventPhase:

PhaseeventPhaseHướng đi
Capturing1document → target
Target2tại target
Bubbling3target → document

Hầu hết event đều bubble. Vài ngoại lệ đáng nhớ: focus, blur, mouseenter, mouseleave không bubble. Thay thế khi cần delegate: focusin / focusout.

Tại sao developer phải quan tâm?

Vì cùng một click, bạn có thể thấy 3 handler khác nhau fire trên 3 element khác nhau — nếu không hiểu thứ tự, bug sẽ xuất hiện kiểu "click vào video thì modal tự đóng" hoặc "click vào menu item thì dropdown cha cũng đóng luôn". Ngoài ra, bubbling mở ra pattern event delegation — lợi thế performance rất lớn khi UI có nhiều child động.

Technical facts cần nhớ

Cú pháp đầy đủ của addEventListener:

element.addEventListener(type, handler, useCapture);
// hoặc
element.addEventListener(type, handler, { capture: true, once: false, passive: false });
  • Param thứ 3 = true → handler fire ở capturing phase.
  • Param thứ 3 = false (default) → handler fire ở bubbling phase.
  • Handler gắn qua HTML attribute (onclick="...") hoặc element.onclick = chỉ thấy được phase 2 và 3. Capturing hoàn toàn vô hình với cú pháp này.
  • event.target — element sâu nhất đã kích hoạt event, không đổi trong suốt quá trình propagate.
  • event.currentTarget (= this trong handler) — element đang chạy handler hiện tại, thay đổi theo từng cấp ancestor.
  • removeEventListener phải truyền đúng cùng giá trị capture đã dùng khi addEventListener, nếu không sẽ không gỡ được.

stopPropagation vs stopImmediatePropagation vs preventDefault

Ba method này hay bị nhầm lẫn, nhưng chúng làm 3 việc khác nhau:

MethodChặn travel capture/bubbleChặn các handler khác trên cùng elementChặn default action của browser
stopPropagation()
stopImmediatePropagation()
preventDefault()

Một số case cụ thể để phân biệt:

  • Click một <a href="/foo"> rồi gọi preventDefault() → browser không điều hướng, nhưng click vẫn bubble lên parent.
  • Cũng click <a> đó nhưng gọi stopPropagation()vẫn điều hướng sang /foo, nhưng parent handler không được gọi.
  • stopPropagation() gọi ở capturing phase cũng đồng thời chặn luôn bubbling phase — event "chết" tại đó.

Event delegation — use case kinh điển

Giả sử bạn có một grid 16 tile, mỗi tile đổi màu random khi click. Cách naive:

document.querySelectorAll('.tile').forEach(tile => {
  tile.addEventListener('click', () => {
    tile.style.background = randomColor();
  });
});

Với 16 tile là OK. Với 1000 row trong table hoặc list render động thì toi. Cách delegation:

const grid = document.querySelector('.grid');
grid.addEventListener('click', (e) => {
  const tile = e.target.closest('.tile');
  if (!tile) return;
  tile.style.background = randomColor();
});

Chỉ 1 listener, hoạt động cho cả tile được thêm sau khi render, tiết kiệm memory và đơn giản hoá cleanup. e.target.closest('.tile') là pattern chuẩn để tìm đúng child cần xử lý bất kể click trúng layer HTML nào bên trong tile.

Những tình huống khác dùng delegation/propagation:

  • Click outside to close: listen click trên document, check !dropdown.contains(e.target) → đóng dropdown.
  • Global keyboard shortcut: listen trên window ở phase capturing để bắt phím trước khi bất kỳ child nào xử lý.
  • Analytics tracking: một listener duy nhất trên document để log mọi click user tạo ra.

Pitfalls — cẩn thận kẻo tự bắn vào chân

  • Đừng stopPropagation lung tung. Mỗi lần bạn gọi, bạn tạo một "dead zone" — nơi các global listener (analytics, modal-close-on-outside, shortcut manager) không còn thấy event. Phần lớn case có thể giải quyết bằng logic bên trong handler (check e.target) thay vì chặn event.
  • React synthetic events khác native events. Trong React 17+, e.stopPropagation() chỉ dừng propagation trong cây virtual của React. Native listener trên document vẫn nhận được. Nếu cần chặn cả hai, dùng e.nativeEvent.stopImmediatePropagation().
  • Capture không preventDefault được child link. Gọi preventDefault() ở phase capturing vẫn chạy, nhưng để huỷ navigation của <a> bạn phải gọi trên đúng element hoặc trong bubbling handler.
  • Focus không bubble. Dùng focusin/focusout hoặc đăng ký listener ở capturing phase thay vì cố delegate focus.

Khi nào nên dùng capturing?

95% use case đời thực chỉ cần bubbling (default). Capturing đáng dùng khi parent bắt buộc phải intercept trước child: focus trap trong modal, global hotkey interceptor, hoặc analytics/security layer cần log event ngay cả khi một handler con gọi stopPropagation trong bubbling.

Tóm lại: hiểu 3 phase, thuộc 3 method control, mặc định delegate thay vì bind loose listener, và chỉ stopPropagation khi bạn biết chính xác mình đang chặn cái gì.

Nguồn: javascript.info — Bubbling and capturing, MDN — Event.stopPropagation(), MDN Learn — Event bubbling.