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 menjalankanpage.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 |
- Widget-nya sudah hilang, tapi satu scroll menjalankan 5 handler. Kerjanya dobel, dan kalau handler-nya mengubah state, hasilnya bisa aneh.
- Tiap handler itu closure: function yang mengingat variabel dari tempat dia dibuat. Di sini tiap handler mengingat
rows, jadi 5 array berisi 1.000 baris ikut tertahan di memory. Garbage collector (bagian JavaScript yang membuang data yang sudah gak dipakai) gak boleh membuangnya selama listener-nya masih terdaftar.
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.
- Kalau listener dipasang dengan
{ capture: true }, lepasnya juga harus dengan{ capture: true }. addEventListenerdengan type dan function yang sama dua kali gak bikin dua listener. Browser cuma menyimpan satu.
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
new AbortController()membuat pengendali yang punyasignal.{ signal: controller.signal }mengikat listener ke signal itu. Di sini arrow function aman, karena yang melepas adalah signal-nya, bukan reference function.controller.abort()melepas semua listener yang terikat ke signal itu.- Untuk listener yang cukup jalan sekali, ada
{ once: true }: browser melepasnya sendiri setelah panggilan pertama.
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)
},
}
}
onmengembalikan functionunsubscribe. Function itu mengingathandlerlewat closure, jadifilter((h) => h !== handler)cuma membuang yang itu.listeners[event] || []membuat event yang belum pernah didaftarkan tetap aman:count("hover")hasilnya 0,emit("hover")juga 0.emitmengembalikan berapa handler yang dipanggil.leakReport(10)mengembalikan nama event yang listener-nya 10 atau lebih. Node.js punya alarm serupa:EventEmittermemberiMaxListenersExceededWarningkalau satu event punya lebih dari 10 listener.
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
- Elements, pilih elemennya, lalu tab Event Listeners di samping: daftar listener yang terpasang di elemen itu.
- Console:
getEventListeners(window)menampilkan listener per type. Function ini cuma ada di Console DevTools, gak bisa dipakai di kode app. - Performance monitor (menu More tools): grafik "JS event listeners" yang naik terus waktu kamu bolak-balik halaman adalah tanda bocor.
- Memory, heap snapshot: ambil snapshot, lakukan interaksinya lalu kembalikan ke keadaan awal (buka lalu tutup widget), ambil snapshot kedua, lalu pilih tampilan Comparison. Kalau objek dari interaksi itu masih tertahan dan jumlahnya bertambah tiap kali diulang, ada listener atau closure yang memegangnya. Ini tanda paling jelas.
⚠️ Jebakan yang sering ketemu
- Arrow function baru di
removeEventListener. Gak ada error, gak ada yang terlepas. Simpan function-nya di variabel, atau pakaiAbortController. - Unsubscribe yang mengosongkan semuanya. Di latihan,
off1()cuma boleh membuang handler pertama.listeners[event] = []membuatcount("click")jadi 0, padahal harus 1. - Lupa event yang belum ada.
listeners["hover"].lengthmelemparTypeErrorkalauhoverbelum pernah didaftarkan. Pakailisteners[event] || []. - Langganan lain yang lupa dihentikan.
setIntervalbutuhclearInterval,IntersectionObserverbutuhdisconnect(), WebSocket butuhclose(). Polanya sama: yang dipasang waktu mount, dilepas waktu unmount.
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.