Mert Özen Her Satırda Daha İleri
Frontend JavaScript Orta — #13

JavaScript Gelişmiş Event'ler: Bubbling, Capturing, Event Delegation ve CustomEvent

Mert Özen 3 Kas 2026 9 dk 4 görüntülenme
JavaScript Gelişmiş Event'ler: Bubbling, Capturing, Event Delegation ve CustomEvent

Event'in capturing, target ve bubbling aşamalarını, stopPropagation'ı, tek listener'la event delegation'ı, once/passive/signal seçeneklerini, CustomEvent'i ve pointer event'lerini örneklerle görüyoruz.

Sürüm notu: Bu yazıdaki kodlar ES2026 ve güncel tarayıcılar (Chrome, Edge, Firefox, Safari) içindir.

Önce Tahmin Et

Bir div'in içinde bir buton var. Üç ayrı yere üç ayrı listener bağladık. Butona tıkladığında konsola hangi satırlar, hangi sırayla düşer?

<div id="outer"><button id="inner">Click</button></div>
<script>
  const outer = document.querySelector("#outer");
  const inner = document.querySelector("#inner");

  outer.addEventListener("click", () => console.log("outer div"));
  inner.addEventListener("click", () => console.log("button"));
  document.addEventListener("click", () => console.log("document"), { capture: true });
</script>

Okumaya devam etmeden önce bir tahmin yap. Çoğu kişi "önce buton, sonra dış div" der ve document'ı ya en sona koyar ya da hiç yazmaz. Cevabı event'in sayfa içinde nasıl yolculuk ettiğini gördükten sonra açıklayacağız.

Event Bir Yolculuktur: Üç Aşama

Daha önce gördüğümüz addEventListener ile bir element'e tıklamayı dinlemeyi biliyorsun. Ama tıklama yalnızca o element'te olmaz. Tarayıcı bir event'i tetiklediğinde onu DOM ağacında bir yol boyunca taşır ve bu yolun üç aşaması vardır.

Bir kargo görevlisini düşün. Paketi teslim etmek için önce sitenin girişinden, sonra apartman kapısından, sonra kat koridorundan geçer. Buna capturing (yakalama) aşaması denir: event window'dan başlayıp hedefe doğru aşağı iner. Kapıyı çaldığı an target (hedef) aşamasıdır. Teslimattan sonra görevli aynı yoldan geri çıkar; bu da bubbling (kabarcıklanma) aşamasıdır, event hedeften yukarı, window'a kadar tırmanır. Yol boyunca her "güvenlik noktası" event'i fark edebilir.

addEventListener varsayılan olarak bubbling aşamasında çalışır. Üçüncü parametreye { capture: true } verirsen listener iniş sırasında çalışır. Aşamayı görmek için event.eventPhase'e bakabilirsin: 1 capturing, 2 target, 3 bubbling. Bu dosyayı kaydedip tarayıcıda aç, butona tıkla ve konsolu izle:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Event flow</title>
</head>
<body>
  <section id="card">
    <button id="buy">Buy</button>
  </section>
  <script>
    const log = (name) => (event) => console.log(name, "phase:", event.eventPhase);

    document.body.addEventListener("click", log("body (capture)"), { capture: true });
    document.querySelector("#card").addEventListener("click", log("section"));
    document.querySelector("#buy").addEventListener("click", log("button"));
    document.body.addEventListener("click", log("body (bubble)"));
  </script>
</body>
</html>

Çıktı sırası: body (capture) phase: 1, button phase: 2, section phase: 3, body (bubble) phase: 3. Baştaki sorunun cevabı da buradan çıkıyor: önce document, çünkü capture ile bağlandı ve iniş sırasında ilk ona uğranır. Sonra buton, sonra dış div. Tahmininde document'ı en başa koyduysan event akışını zaten sezmişsin demektir.

target ile currentTarget Farkı

Event yolculuk ederken event object'i aynı kalır ama iki property'si farklı şeyler söyler. event.target event'in gerçekten başladığı element'tir, yani tıklanan şey. event.currentTarget ise o an çalışan listener'ın bağlı olduğu element'tir. Kargo örneğinde target paketin gideceği daire, currentTarget ise paketi o an kontrol eden güvenlik noktası.

document.querySelector("#card").addEventListener("click", (event) => {
  console.log("target:", event.target.id);               // "buy"
  console.log("currentTarget:", event.currentTarget.id); // "card"
});

Daha önce gördüğümüz this kuralını hatırla: normal bir function ile yazılmış listener'da this, currentTarget ile aynıdır. Arrow function kendi this'ini getirmediği için orada event.currentTarget kullanmak daha güvenli ve okunaklı.

Yayılımı Durdurmak

Bazen bir event'in yukarı tırmanmasını istemezsin. Örneğin tıklanınca kapanan bir modal penceren var ve arka plan katmanına tıklamak modalı kapatıyor, ama modalın içindeki butona tıklamak kapatmamalı. Bunun için event.stopPropagation() var: event o noktadan sonra başka bir element'e geçmez.

dialogBox.addEventListener("click", (event) => {
  event.stopPropagation(); // tıklama arka plana ulaşmaz
});

backdrop.addEventListener("click", () => closeModal());

stopImmediatePropagation() bir adım daha ileri gider: aynı element'e bağlı diğer listener'ları da çalıştırmaz. Bunu daha önce gördüğümüz preventDefault() ile karıştırma. preventDefault tarayıcının varsayılan davranışını (linke gitmek, formu göndermek) iptal eder ama event'in yolculuğuna dokunmaz. stopPropagation ise yolculuğu keser ama varsayılan davranışa dokunmaz.

Bir uyarı: stopPropagation'ı alışkanlık hâline getirme. Sayfadaki istatistik betikleri, "dışarı tıklayınca menüyü kapat" mantıkları ve ileride göreceğin delegation kodları çoğu zaman document üzerinde dinler. Yolculuğu yarıda kesersen bunlar sessizce çalışmaz olur ve hatanın kaynağını bulmak saatler sürer. Genellikle event.target'a bakıp karar vermek daha temiz bir çözümdür.

Event Delegation: Tek Listener, Yüzlerce Element

Bubbling'in en büyük faydası burada. Yüz satırlık bir listenin her satırına ayrı listener bağlamak yerine, listenin kendisine tek bir listener bağlarsın. Hangi satıra tıklandığını event.target'tan öğrenirsin. Buna event delegation denir. Apartman yöneticisini düşün: her daireye ayrı bir görevli koymak yerine girişte tek bir kişi durur ve gelen paketin hangi daireye ait olduğuna bakar.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Event delegation</title>
</head>
<body>
  <button id="add">Add task</button>
  <ul id="tasks">
    <li data-id="1">Buy milk <button data-action="delete"><span>Delete</span></button></li>
    <li data-id="2">Pay the bill <button data-action="delete"><span>Delete</span></button></li>
  </ul>
  <script>
    const list = document.querySelector("#tasks");
    let nextId = 3;

    list.addEventListener("click", (event) => {
      const button = event.target.closest('[data-action="delete"]');
      if (!button || !list.contains(button)) return;
      const item = button.closest("li");
      console.log("Deleting:", item.dataset.id);
      item.remove();
    });

    document.querySelector("#add").addEventListener("click", () => {
      list.insertAdjacentHTML(
        "beforeend",
        `<li data-id="${nextId}">New task ${nextId} <button data-action="delete">Delete</button></li>`
      );
      nextId++;
    });
  </script>
</body>
</html>

Burada üç şey önemli. Birincisi, sonradan eklenen satırlar da hiçbir ek kod yazmadan çalışıyor, çünkü listener satırlarda değil listede. İkincisi, butonun içinde bir span var; tıklama span'e denk gelirse event.target span olur. Daha önce gördüğümüz closest tam bu yüzden var: target'tan yukarı doğru gidip aradığın element'i bulur. Üçüncüsü, dataset ile hangi satır üzerinde işlem yaptığını HTML'in içinden okuyorsun.

Her event kabarcıklanmaz. focus ve blur yukarı tırmanmaz; delegation için onların kabarcıklanan kardeşleri focusin ve focusout'u kullan. mouseenter ve mouseleave da kabarcıklanmaz; onların yerine mouseover ve mouseout var.

Listener Seçenekleri: once, passive, signal

addEventListener'ın üçüncü parametresi bir seçenek object'i alabilir. capture'ı gördük; diğer üçü günlük işte çok işe yarar.

// Yalnızca bir kez çalışır, sonra kendini kaldırır
button.addEventListener("click", () => console.log("First click"), { once: true });

// Tarayıcıya "preventDefault çağırmayacağım" sözü verir
window.addEventListener("wheel", onWheel, { passive: true });

// Bir AbortController ile birden çok listener'ı tek seferde kaldırmak
const controller = new AbortController();
window.addEventListener("resize", onResize, { signal: controller.signal });
window.addEventListener("keydown", onKey, { signal: controller.signal });

controller.abort(); // ikisi birden kaldırıldı

once: true, "Hoş geldin" mesajı ya da ilk etkileşimde ses başlatmak gibi tek seferlik işler için idealdir. passive: true ise kaydırma performansıyla ilgili. Tarayıcı wheel ya da touchmove gibi bir event'te sayfayı kaydırmadan önce listener'ın preventDefault çağırıp çağırmayacağını bilmek zorunda; bilemezse bekler ve kaydırma takılır. Passive listener bu bekleyişi kaldırır. Böyle bir listener içinde preventDefault çağırırsan hiçbir şey olmaz, tarayıcı konsola bir uyarı düşebilir. Bir ayrıntı: window, document ve body üzerindeki wheel, touchstart ve touchmove listener'ları tarayıcılarda zaten varsayılan olarak passive'dir. Orada kaydırmayı engellemen gerekiyorsa açıkça { passive: false } yazmalısın.

signal seçeneği, daha önce Fetch yazısında gördüğümüz AbortController'ın burada da işe yaradığını gösteriyor. Özellikle bir component'i sayfadan kaldırırken ona ait bütün listener'ları tek satırda temizlemek için çok rahat.

Kendi Event'ini Üret: CustomEvent

Tarayıcının event'leri yetmediğinde kendi event'ini tanımlayabilirsin. CustomEvent ile bir event oluşturur, detail içine istediğin veriyi koyar, dispatchEvent ile bir element'ten fırlatırsın. Böylece sayfanın bir parçası "sepete ürün eklendi" diye duyuru yapar, başka bir parçası da bunu dinler; ikisi birbirini tanımak zorunda kalmaz.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>CustomEvent</title>
</head>
<body>
  <p>Cart: <span id="count">0</span> items</p>
  <div id="products">
    <button data-name="Pen">Add pen</button>
    <button data-name="Notebook">Add notebook</button>
  </div>
  <script>
    let count = 0;

    document.addEventListener("cart:add", (event) => {
      count++;
      document.querySelector("#count").textContent = count;
      console.log("Added:", event.detail.name);
    });

    document.querySelector("#products").addEventListener("click", (event) => {
      const button = event.target.closest("button");
      if (!button) return;
      button.dispatchEvent(new CustomEvent("cart:add", {
        bubbles: true,
        detail: { name: button.dataset.name }
      }));
      console.log("dispatchEvent finished");
    });
  </script>
</body>
</html>

İki ayrıntıya dikkat et. Kendi oluşturduğun event'ler varsayılan olarak kabarcıklanmaz; bubbles: true yazmazsan butondan çıkan event document'a hiç ulaşmaz. İkincisi, dispatchEvent senkron çalışır. Konsolda önce "Added", sonra "dispatchEvent finished" görürsün; yani bütün listener'lar dispatchEvent satırı dönmeden çalışıp biter. Event isminde cart:add gibi bir ön ek kullanmak, tarayıcının kendi event isimleriyle çakışmayı önler.

Klavye, Fare ve Pointer Event'leri

Klavye tarafında keydown ve keyup kullanırsın; eski keypress kullanımdan kalktı. event.key basılan tuşun anlamını verir ("a", "Enter", "ArrowUp"), event.code ise klavyedeki fiziksel konumunu ("KeyA"). Türkçe Q ile F klavye arasında aynı fiziksel tuş farklı harf üretir; kısayol tanımlarken bu farkı bilmek önemli. ctrlKey, metaKey, shiftKey, altKey değiştirici tuşları, repeat ise tuşa basılı tutulduğunu söyler.

document.addEventListener("keydown", (event) => {
  if ((event.ctrlKey || event.metaKey) && event.key === "k") {
    event.preventDefault();
    console.log("Open the search box");
  }
});

Fare tarafında click, dblclick, contextmenu, mousedown, mouseup ve mousemove var. clientX/clientY görünür alana göre konumu, button hangi tuşa basıldığını verir. Ama bugün dokunmatik ekran, kalem ve fareyi tek bir modelle karşılayan pointer event'leri tercih etmek daha doğru: pointerdown, pointermove, pointerup. event.pointerType sana "mouse", "touch" ya da "pen" der. Aşağıdaki kutuyu fareyle de parmakla da sürükleyebilirsin:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Drag with pointer events</title>
  <style>
    #box { position: absolute; width: 80px; height: 80px; background: tomato; touch-action: none; }
  </style>
</head>
<body>
  <div id="box"></div>
  <script>
    const box = document.querySelector("#box");
    let offsetX = 0;
    let offsetY = 0;

    box.addEventListener("pointerdown", (event) => {
      offsetX = event.clientX - box.offsetLeft;
      offsetY = event.clientY - box.offsetTop;
      box.setPointerCapture(event.pointerId);
      console.log("Input type:", event.pointerType);
    });

    box.addEventListener("pointermove", (event) => {
      if (!box.hasPointerCapture(event.pointerId)) return;
      box.style.left = `${event.clientX - offsetX}px`;
      box.style.top = `${event.clientY - offsetY}px`;
    });
  </script>
</body>
</html>

setPointerCapture, imleç kutunun dışına hızla kaysa bile sonraki pointer event'lerinin kutuya gelmesini sağlar; parmağı kaldırdığında yakalama kendiliğinden biter. CSS'teki touch-action: none ise dokunmatik ekranda tarayıcının sayfayı kaydırmaya kalkmasını engeller.

Sık Düşülen Tuzaklar

Anonim function'la eklenen listener'ı kaldıramazsın. removeEventListener aynı function referansını ister; addEventListener("click", () => {...}) ile eklediğin şeyi sonradan bulamazsın. Function'ı bir değişkende tut ya da signal kullan.

Capture bayrağı eşleşmeli. { capture: true } ile eklenen listener, removeEventListener("click", fn) ile kalkmaz; aynı bayrakla kaldırman gerekir. once ve passive eşleşmede dikkate alınmaz, yalnızca capture önemlidir.

Delegation'da event.target'a doğrudan güvenmek. Butonun içinde bir ikon ya da span varsa target o olur; her zaman closest ile aradığın element'e çık.

Kendi event'inde bubbles'ı unutmak. Event fırlatılır, kimse duymaz ve hiçbir hata da görmezsin. Sessiz hataların en sinsisi budur.

Küçük Bir Deneme

★☆☆ Delegation örneğindeki listeye bir "done" butonu ekle. Tıklanınca satırın üstünü çizsin, ama listener sayısı yine bir olsun.

★★☆ Çıktıyı tahmin et, sonra dene: ilk örnekteki dosyada section listener'ının içine event.stopPropagation() ekle. Butona tıkladığında konsolda hangi satırlar kalır, hangileri kaybolur? body (capture) satırının neden hâlâ göründüğünü açıkla.

★★★ Escape tuşuna basılınca document üzerinden ui:close adlı bir CustomEvent fırlat. Sayfadaki iki farklı panel bu event'i dinleyip kendini gizlesin. Panellerden biri kapandıktan sonra listener'ını AbortController ile kaldır.

Event'lerin en yoğun yaşadığı yer formlardır: her tuş vuruşu, her seçim, her gönderim bir event. Bir sonraki yazıda formlara geri dönüyoruz; bu kez FormData, tarayıcının yerleşik doğrulama API'si ve formu sayfa yenilenmeden fetch ile göndermek var.