Aplikasi Anda berjalan lancar selama sepuluh menit. Kemudian gulirannya menjadi macet. Setelah setengah jam, tab tersebut mencapai satu gigabyte. Akhirnya, halaman mati dengan kesalahan out-of-memory dan Anda tidak memiliki stack trace untuk ditunjukkan.

Ini bukan masalah performa render. React DevTools Profiler akan terlihat tenang karena masalahnya bukan seberapa sering komponen menggambar ulang (redraw). Masalahnya adalah apa yang tetap hidup setelah komponen tersebut di-unmount. Sebuah referensi yang tersesat di suatu tempat dalam JavaScript heap menahan seluruh pohon node DOM, closure, dan state. Browser tidak dapat mengambil kembali bagian mana pun darinya, sehingga penggunaan memori terus meningkat hingga proses tersebut tumbang.

Membaca kode sumber Anda tidak akan mengungkap kebocoran tersebut. Bug ini berada di celah antara apa yang Anda pikir telah di-unmount dan apa yang sebenarnya dilihat oleh garbage collector. V8 hanya membebaskan objek yang memiliki nol retaining path. Jika sebuah event listener yang nakal, observer yang tidak dibersihkan, atau closure yang berumur panjang memegang satu pointer saja ke sebuah fiber atau node DOM, seluruh sub-pohon komponen akan tetap bertahan. Anda melakukan unmount pada sebuah modal, tetapi node-node yang terlepas (detached nodes) tetap ada di memori karena sebuah listener pada window masih menunjuk ke sebuah handler yang didefinisikan di dalam modal tersebut.

Untuk membuktikan di mana kebocoran itu berada, Anda perlu melihat heap, bukan editor.

Mengapa Heap Mengungkapkan Kebenaran

Chrome DevTools memberi Anda jendela langsung ke apa yang dilihat oleh garbage collector. Tab Memory dapat merekam heap snapshots: inventaris lengkap dari setiap objek, node DOM, dan closure yang saat ini ditahan dalam memori JavaScript. Dengan membandingkan dua snapshot—satu sebelum dugaan kebocoran dan satu setelahnya—Anda dapat mengisolasi objek mana tepatnya yang gagal dihapus.

Ini bukan teori abstrak. Satu komponen React yang bocor dapat menahan ribuan objek HTMLElement yang terlepas (detached). Objek-objek tersebut tidak lagi terhubung ke dokumen yang terlihat, tetapi referensi JavaScript mencegahnya untuk dikumpulkan (collected). Mereka muncul di tampilan perbandingan dengan nama konstruktor Detached HTMLElement. Saat Anda melihat jumlahnya berlipat ganda, Anda telah menemukan kebocoran Anda.

Alur Kerja Chrome DevTools

Mulailah dengan bersih. Tutup tab browser yang tidak terkait, nonaktifkan ekstensi yang tidak terkait, dan biarkan aplikasi Anda mencapai kondisi stabil. Buka Chrome DevTools, beralih ke tab Memory, dan pilih Heap snapshot. Klik Take snapshot. Baseline ini menangkap jejak memori awal Anda.

Sekarang lakukan tindakan pengguna yang Anda curigai. Buka dan tutup modal yang berat tersebut. Mount dan unmount widget tersebut. Navigasi ke rute tersebut dan kembali lagi. Setelah UI kembali ke keadaan visual aslinya, klik ikon tempat sampah di tab Memory. Ini memaksa proses garbage collection global. Objek sementara dari siklus render seharusnya hilang. Apa pun yang tersisa adalah kandidat nyata untuk kebocoran.

Klik Take snapshot lagi. Sekarang Anda memiliki dua foto memori. Ubah tampilan dari Summary ke Comparison. Atur cakupan perbandingan (comparison scope) ke snapshot pertama. Alat ini akan menunjukkan hanya apa yang berubah di antara kedua tangkapan tersebut, menghilangkan gangguan (noise) dari runtime.

Urutkan berdasarkan Delta. Cari jumlah objek yang bertambah. Berikan perhatian khusus pada konstruktor seperti Detached HTMLElement, Array, Function, atau bahkan instansi kelas bernama dari kode sumber Anda sendiri. Delta yang meningkat berarti objek dibuat selama tindakan Anda dan tidak dikumpulkan setelahnya.

Menelusuri Retaining Path

Saat Anda menemukan elemen yang bocor, pilih elemen tersebut. Panel bagian bawah menampilkan retaining path: rantai referensi yang menjelaskan mengapa objek ini masih hidup. Rantai tersebut mungkin berjalan dari div yang terlepas melalui properti internal React, masuk ke dalam sebuah closure, dan akhirnya mendarat pada sebuah event listener yang terdaftar di dalam salah satu komponen Anda. Tautan terakhir dalam rantai tersebut adalah nomor baris kode Anda.

Di sinilah Anda berpindah dari diagnosis ke akar masalah. Jika retaining path berakhir di window.addEventListener, Anda tahu bahwa listener global sedang menyandera komponen Anda. Jika berakhir di instansi IntersectionObserver, Anda tahu bahwa sebuah observer masih mengawasi node yang seharusnya sudah dikumpulkan oleh garbage collector.

Penyebab Umum di React

Kebocoran memori di React biasanya terbagi dalam tiga pola.

Orphaned global listeners. Sebuah useEffect terhubung ke window atau document untuk melacak posisi gulir (scroll), penekanan tombol, atau peristiwa perubahan ukuran (resize). Jika effect tersebut tidak mengembalikan fungsi pembersihan (cleanup function) yang memanggil removeEventListener, listener tersebut akan bertahan selama halaman masih terbuka. Karena listener tersebut adalah sebuah closure, ia menjaga seluruh cakupan (scope) komponen tetap hidup lama setelah React melakukan unmount pada komponen tersebut.

Observer yang tidak dibersihkan. IntersectionObserver dan ResizeObserver sangat kuat, tetapi mereka membuat referensi native di luar kendali React. Jika Anda menginstansiasi observer di dalam komponen dan lupa memanggil disconnect() pada fase pembersihan, observer tersebut akan menahan node DOM target, dan node DOM tersebut akan menahan React fibers, props, dan state.

Jebakan closure. Saat Anda mendefinisikan fungsi di dalam komponen dan meneruskannya ke pustaka pihak ketiga, cache global, atau bahkan setTimeout, fungsi tersebut melakukan closure terhadap setiap variabel dalam cakupan leksikalnya. Jika pemilik eksternal menyimpan fungsi tersebut, ia juga akan menyimpan seluruh cakupan komponen Anda bersamanya.

Pola Pembersihan yang Benar-benar Berhasil

Memperbaiki kebocoran berarti memutus setiap jalur retensi yang Anda temukan dalam snapshot.

Selalu kembalikan fungsi pembersihan dari useEffect. Jika Anda menambahkan listener di dalam effect, hapuslah di sana.

Gunakan useCallback untuk handler apa pun yang Anda tempelkan ke DOM atau window. Tanpanya, setiap render akan membuat referensi fungsi baru. Jika Anda memanggil addEventListener dengan satu referensi dan kemudian memanggil removeEventListener dengan referensi yang berbeda, penghapusan tersebut akan gagal secara diam-diam. Listener asli akan tetap ada di window selamanya. useCallback menjaga referensi tetap stabil sehingga proses add dan remove cocok secara tepat.

Tangani observer dengan disiplin yang sama. Simpan instansi observer dalam sebuah ref atau variabel lokal di dalam effect. Di dalam fungsi pembersihan, panggil observer.disconnect(). Jangan berasumsi bahwa unmounting komponen akan menghentikan observer. Itu tidak terjadi.

Jika komponen Anda mempublikasikan apa pun ke namespace global atau layanan singleton, hapus referensi tersebut saat unmount. V8 engine hanya dapat mengambil kembali memori ketika sebuah objek benar-benar tidak dapat dijangkau. Meninggalkan hook pada window atau entri dalam Map tingkat modul menciptakan jembatan tak terlihat yang membuat heap terus tumbuh.

Intisari Sebenarnya

Kebocoran memori tidak langsung membuat aplikasi Anda crash. Mereka menumpuk satu per satu node yang terlepas selama sesi pengguna yang panjang. Solusinya bukanlah upgrade pustaka atau flag compiler. Solusinya adalah kebiasaan membuktikan logika pembersihan Anda dengan heap snapshots.

Ambil baseline, picu alur yang dicurigai, paksa garbage collection, dan bandingkan. Jika delta menunjukkan pertumbuhan, periksa jalur retensi, temukan listener atau observer yang seharusnya tidak ada, dan putus referensinya. Jalankan tes lagi. Ketika delta tetap datar, Anda telah benar-benar menyelesaikannya. Aplikasi Anda akan tetap responsif, dan pengguna Anda tidak akan kehilangan pekerjaan mereka karena tab browser yang membeku.