Tab browser pengguna Anda membeku setelah tiga puluh menit. UI tersendat-sendat. Kemudian browser crash dengan error out-of-memory.

Anda meninjau kode komponen baris demi baris dan tidak melihat ada yang salah. Itulah bagian yang menyebalkan dari memory leak di React. Bug tersebut tidak terletak pada sintaks JSX atau logika hook Anda. Bug itu berada di celah antara komponen Anda dan garbage collector browser. Sebuah event listener yang tersesat atau closure yang berumur panjang menghentikan collector untuk mengambil kembali memori. Anda melakukan unmount pada sebuah komponen, tetapi satu referensi yang tertinggal menjaga seluruh tree tetap hidup di dalam heap. Anda tidak bisa menemukan kebocoran ini hanya dengan membaca kode. Masalahnya tetap tersembunyi di bawah permukaan, tidak terlihat oleh mata dan tidak terdeteksi dalam unit test.

Mengapa Kebocoran Tersembunyi di Depan Mata

Engine JavaScript seperti V8 mengelola memori secara otomatis. Ketika tidak ada jalur referensi dari root ke sebuah objek, engine akan menandai objek tersebut sebagai garbage dan mengambil kembali ruangnya. Proses ini berjalan dengan baik sampai sebuah referensi tersembunyi bertahan lebih lama dari yang Anda inginkan.

Di React, bahaya sering kali muncul pada batas antara komponen dan DOM. Anda mungkin memasang listener resize ke window di dalam sebuah modal, atau berlangganan ke WebSocket di sebuah widget dashboard. Saat pengguna menutup modal atau berpindah halaman, komponen tersebut di-unmount. Jika langganan (subscription) tersebut masih bertahan, engine akan melihat referensi yang valid dari objek window global turun ke handler Anda, dan dari handler Anda kembali ke closure komponen tersebut. Komponen, props, state, dan seluruh subtree node DOM-nya tetap tertahan di memori. Selama ratusan interaksi, objek-objek yang tertahan ini akan menumpuk. Penggunaan memori naik dalam pola gergaji (sawtooth pattern) yang tidak pernah benar-benar turun kembali.

Masalah Dashboard Enterprise

Hal ini paling sering terjadi pada dashboard enterprise di mana pengguna tetap berada di satu halaman selama berjam-jam. Bayangkan panel pemantauan, tampilan analitik, atau sistem ticketing. Seorang pengguna membuka modal detail, memfilter dataset yang besar, atau berpindah tab dalam sebuah single-page app. Setiap interaksi individu terasa baik-baik saja. Namun seiring berjalannya waktu, node yang yatim (orphaned nodes) dan listener yang terlepas (detached listeners) menumpuk. Aplikasi melambat bukan karena satu render yang berat, melainkan karena heap tumbuh cukup besar untuk memicu jeda garbage collection yang sering dan mahal.

Berhentilah menebak-nebak tentang hook useEffect Anda. Satu-satunya cara untuk mengetahui apakah ada kebocoran adalah dengan mengukur heap secara langsung. Chrome DevTools memberi Anda visibilitas tersebut.

Berburu Kebocoran dengan Chrome DevTools

Anda memerlukan urutan yang dapat direproduksi dan beberapa menit perhatian penuh. Buka aplikasi Anda di Chrome, luncurkan DevTools, dan navigasikan ke tab Memory.

Rekam baseline. Pilih Heap snapshot dan klik Take snapshot. Ini menangkap setiap objek yang saat ini ada di dalam JavaScript heap dan menunjukkan memori awal Anda. Lakukan ini setelah halaman berada dalam kondisi idle awal, bukan saat pemuatan awal, sehingga Anda hanya mengukur pertumbuhan yang disebabkan oleh tindakan pengguna.

Picu aksinya. Lakukan interaksi UI yang persis seperti yang Anda curigai menyebabkan kebocoran. Buka dan tutup modal, aktifkan/nonaktifkan chart yang kompleks, atau ubah rute dan navigasikan kembali. Setelah selesai, kembalikan aplikasi ke keadaan visual aslinya. Langkah ini sangat krusial. Anda ingin UI terlihat identik dengan tampilannya saat baseline. Jika heap telah bertambah meskipun UI tampak kosong, Anda memiliki bukti kuat adanya kebocoran.

Paksa garbage collection. Klik ikon tempat sampah di tab Memory. Ini memicu siklus GC penuh dan membersihkan objek sementara yang memang secara sah bertahan hingga koleksi berikutnya. Apa yang tersisa adalah kebocoran yang sebenarnya, yaitu objek-objek yang seharusnya sudah dikoleksi tetapi tetap hidup karena referensi yang tidak disengaja.

Ambil snapshot kedua. Klik Take snapshot lagi. Sekarang Anda memiliki dua foto dari heap yang diambil di bawah kondisi UI yang sama.

Bandingkan hasil. Ubah tampilan dari Summary ke Comparison. Atur baseline ke snapshot pertama Anda dan snapshot yang dibandingkan ke snapshot kedua. Tampilan Comparison mencantumkan setiap kategori objek dan menunjukkan delta, yaitu perubahan bersih dalam jumlah objek di antara kedua tangkapan tersebut.

Urutkan berdasarkan Delta. Cari kategori yang meningkat secara signifikan. Fokuslah terutama pada Detached HTMLElement dan React fiber nodes. Elemen HTML yang terlepas (detached) adalah node DOM yang tidak lagi terhubung ke tree dokumen yang aktif, namun beberapa referensi JavaScript masih menahannya. Ini adalah bukti kuat (smoking guns). Elemen-elemen ini seharusnya tidak ada setelah Anda menutup modal atau melakukan unmount pada sebuah komponen.

Membaca Retaining Path

Saat Anda memilih elemen yang terlepas (detached element) dalam snapshot, Chrome akan menampilkan retaining path di panel bawah. Jalur ini adalah rantai referensi dari root hingga ke objek yang dipilih. Ikuti dengan saksama. Anda akan sering menemukan event listener, IntersectionObserver, ID setInterval, atau sebuah closure yang merujuk ke baris tertentu dalam komponen Anda.

Carilah nama-nama yang Anda kenali. Jika Anda melihat listener yang terpasang pada window dengan nama fungsi dari basis kode (codebase) Anda, Anda telah menemukan jangkar (anchor). Objek yang menahan listener tersebutlah yang membuat seluruh komponen Anda tetap hidup. Terkadang rantai tersebut melewati pustaka pihak ketiga. Dalam kasus tersebut, periksa apakah pustaka tersebut memerlukan pemanggilan teardown eksplisit yang lupa Anda panggil dalam fungsi pembersihan (cleanup function).

Memperbaiki Akar Masalah

Setelah Anda mengidentifikasi retaining path, perbaikannya biasanya bersifat mekanis tetapi memerlukan disiplin di seluruh tim.

Gunakan fungsi pembersihan (cleanup functions). Selalu kembalikan fungsi pembersihan dalam useEffect saat Anda menambahkan listener ke window atau document. Jika effect Anda berlangganan (subscribe) ke event resize, hapus langganan tersebut sebelum komponen di-unmount. Pembersihan akan berjalan saat React membongkar (tear down) komponen, memberi Anda hook yang terjamin untuk memutuskan koneksi eksternal.

Stabilkan referensi. Bungkus handler Anda dalam useCallback. Ini memastikan Anda meneruskan referensi fungsi yang persis sama ke removeEventListener seperti yang Anda berikan ke addEventListener pada awalnya. Jika Anda mendaftarkan fungsi inline seperti window.addEventListener('resize', () => { ... }) dan kemudian mencoba menghapusnya dengan fungsi inline lain, referensinya tidak akan cocok. Listener akan tetap terpasang. Closure di dalamnya akan menjaga state komponen Anda tetap hidup. useCallback dengan dependency array yang stabil mencegah ketidakcocokan identitas ini.

Putuskan tautan global. Ingatlah bahwa objek target event browser, seperti window dan document, hidup selama halaman tersebut aktif. Referensi apa pun dari mereka ke dalam komponen Anda bertindak sebagai jangkar global. Menghapus listener akan memutus jangkar tersebut dan membiarkan V8 engine menyapu bersih state komponen dan node DOM selama siklus garbage collection berikutnya.

Pertimbangkan sebuah modal yang melacak lebar jendela (window width). Tanpa pembersihan, setiap kali pengguna membuka modal, sebuah listener baru akan terpasang. Listener lama tidak pernah terlepas karena instansi komponen tempat mereka berasal sudah hilang, namun fungsi itu sendiri bersifat anonim dan hilang. Menggunakan handler bernama yang dibungkus dalam useCallback, ditambah fungsi pembersihan yang memanggil removeEventListener, akan menutup siklus tersebut dengan bersih.

Pelajaran Nyata

Kebocoran memori (memory leaks) di React jarang menunjukkan dirinya dengan pesan kesalahan yang jelas. Mereka menunjukkan dirinya melalui tab yang semakin berat semakin lama dibiarkan terbuka. Jangan membuang waktu berspekulasi tentang hook mana yang bersalah. Buka tab Memory, paksa garbage collection, dan bandingkan snapshot. Biarkan heap profiler menunjukkan retaining path yang tepat kepada Anda. Kemudian tulis fungsi pembersihan, stabilkan referensi callback, dan putuskan tautan global. Pengguna Anda tidak akan menyadari perbaikannya secara langsung, tetapi mereka akan menyadari bahwa dasbor tetap berjalan lancar hingga akhir hari.