Event Listener & Closure Leaks — Debugging

Salah satu memory leak paling umum di SPA: event listener yang tidak di-remove, plus closure yang masih pegang reference ke data besar. Kasus 1: Listener di…

Event listener adalah function yang kamu daftarkan ke sebuah elemen supaya browser memanggilnya saat sesuatu terjadi, misalnya klik atau scroll. Listener bocor (leak) kalau dia tetap terdaftar setelah bagian halaman yang butuh dia sudah hilang: dia tetap jalan tiap ada event, dan semua data yang dia pegang gak bisa dibuang dari memory. Bayangin langganan koran. Kamu pindah rumah tapi lupa berhenti langganan, jadi koran tetap dikirim ke alamat lama tiap pagi. Pindah lima kali tanpa berhenti langganan, tiap pagi ada lima koran yang dikirim percuma.

🧪 Tebakan cepat: Sebuah widget dipasang lalu dicopot 5 kali. Tiap dipasang, dia menjalankan page.addEventListener("scroll", () => ...). Tiap dicopot, dia menjalankan page.removeEventListener("scroll", () => ...) dengan arrow function yang ditulis ulang persis sama. Berapa listener yang masih aktif? Jawabannya ada di bagian "Lepas dengan function yang sama".

Listener yang ketinggalan

Di single page app (SPA), komponen dipasang (mount) dan dicopot (unmount) terus-menerus waktu user pindah halaman. Halamannya sendiri gak pernah dimuat ulang, jadi listener yang dipasang ke page (di app sungguhan biasanya window atau document) tetap ada selama tab terbuka.

const page = document.getElementById("halaman")

function mountWidget() {
  const rows = new Array(1000).fill("baris") // data milik widget
  page.addEventListener("scroll", () => {
    console.log("scroll", rows.length)
  })
  // gak ada yang melepas listener-nya
}
Kejadian Widget di layar Listener aktif Handler jalan per scroll
mount ke-1 ada 1 1
unmount ke-1 hilang 1 1
mount + unmount 5 kali hilang 5 5

Lepas dengan function yang sama

removeEventListener mencari listener yang type-nya sama dan function-nya sama persis (reference yang sama, bukan isi yang sama). Dua arrow function yang ditulis sama tetap dua function berbeda.

// Bug: arrow function di remove adalah function baru
page.addEventListener("scroll", () => console.log("scroll"))
page.removeEventListener("scroll", () => console.log("scroll")) // gak melepas apa-apa

// Benar: simpan function-nya di variabel, pakai untuk add dan remove
const handler = () => console.log("scroll")
page.addEventListener("scroll", handler)
page.removeEventListener("scroll", handler) // terlepas

Jadi di tebakan tadi, listener yang masih aktif ada 5. Itu jawaban tebakannya. removeEventListener gak menemukan function yang cocok, dan dia juga gak melempar error, jadi bug-nya diam-diam.

AbortController: satu tombol untuk melepas

Cara modern yang lebih ringkas: pasang listener dengan signal, lalu panggil abort() untuk melepas semuanya sekaligus.

function mountWidget() {
  const controller = new AbortController()
  const rows = new Array(1000).fill("baris")

  const options = { signal: controller.signal }

  page.addEventListener("scroll", () => console.log("scroll", rows.length), options)
  page.addEventListener("resize", () => console.log("resize"), options)

  return () => controller.abort() // function unmount: lepas dua-duanya
}

const unmount = mountWidget()
unmount() // listener aktif kembali 0

Di React: cleanup di useEffect

Function yang di-return dari useEffect adalah cleanup. React menjalankannya saat komponen unmount, atau sebelum effect jalan lagi karena dependency berubah.

function ScrollInfo() {
  useEffect(() => {
    const handler = () => console.log(window.scrollY)
    window.addEventListener("scroll", handler)
    return () => window.removeEventListener("scroll", handler) // reference yang sama
  }, [])

  return <p>Scroll halaman ini</p>
}

Di mode development dengan <StrictMode>, React sengaja menjalankan setup, cleanup, lalu setup lagi waktu komponen pertama kali dipasang. Kalau cleanup-nya lupa, listener-nya langsung dobel, jadi bug-nya ketahuan lebih cepat.

Hitung listener-nya sendiri

Browser gak punya cara di kode biasa untuk menanyakan "ada berapa listener di elemen ini?". Jadi banyak library menghitung sendiri, persis yang kamu bangun di latihan: event emitter kecil yang menyimpan listener per nama event.

function createEmitter() {
  const listeners = {} // { click: [handlerA, handlerB], scroll: [...] }

  return {
    on(event, handler) {
      if (!listeners[event]) listeners[event] = []
      listeners[event].push(handler)
      return () => { // unsubscribe: lepas handler yang ini saja
        listeners[event] = listeners[event].filter((h) => h !== handler)
      }
    },
    emit(event, payload) {
      const list = listeners[event] || []
      list.forEach((h) => h(payload))
      return list.length
    },
    count(event) {
      return (listeners[event] || []).length
    },
    leakReport(threshold) {
      return Object.keys(listeners).filter((event) => listeners[event].length >= threshold)
    },
  }
}

Dengan kode awal di latihan: dua on("click") dan satu on("scroll") membuat count("click") 2, count("scroll") 1, count("hover") 0, dan emit("click") 2. Setelah off1(), count("click") jadi 1. Ditambah 15 listener scroll, count("scroll") jadi 16, jadi leakReport(10) berisi satu nama: scroll.

Cara mengecek di Chrome DevTools

⚠️ Jebakan yang sering ketemu

TL;DR: Listener yang gak dilepas tetap jalan dan menahan semua data di closure-nya, jadi mount dan unmount 5 kali tanpa cleanup menyisakan 5 listener. removeEventListener cuma melepas function yang sama persis (reference yang sama, capture yang sama), jadi simpan handler di variabel. AbortController melepas semua listener yang memakai signal-nya dengan satu abort(). Di React, lepas listener di function cleanup yang di-return useEffect. Untuk menghitung listener, simpan sendiri per nama event: on mengembalikan unsubscribe yang membuang handler itu saja, count dan emit aman untuk event kosong, dan leakReport(threshold) menyebut event yang listener-nya sampai ambang. Di DevTools, heap snapshot yang retained objects-nya terus bertambah setelah interaksi di-reset adalah tanda bocor paling jelas.

Yang akan kamu pelajari