Tab pelayar pengguna anda membeku selepas tiga puluh minit. UI tersangkut-sangkut. Kemudian pelayar ranap dengan ralat out-of-memory.

Anda menyemak kod komponen baris demi baris dan tidak melihat sebarang masalah. Itulah bahagian yang menyakitkan hati tentang kebocoran memori (memory leaks) dalam React. Pepijat tersebut tidak terletak pada sintaks JSX atau logik hook anda. Ia wujud dalam jurang antara komponen anda dan pengumpul sampah (garbage collector) pelayar. Pendengar acara (event listener) yang tersasar atau closure yang berumur panjang menghalang pengumpul daripada menuntut semula memori. Anda melakukan unmount pada komponen, tetapi satu rujukan yang masih ada mengekalkan keseluruhan pokok (tree) dalam heap. Anda tidak boleh mencari kebocoran ini hanya dengan membaca kod. Masalah tersebut kekal tersembunyi di bawah permukaan, tidak kelihatan pada mata kasar dan senyap dalam ujian unit (unit tests).

Mengapa Kebocoran Tersembunyi di Depan Mata

Enjin JavaScript seperti V8 menguruskan memori secara automatik. Apabila tiada laluan rujukan wujud dari akar (root) ke sesuatu objek, enjin akan menandakan objek tersebut sebagai sampah dan menuntut semula ruang tersebut. Proses ini berfungsi dengan baik sehinggalah rujukan tersembunyi bertahan lebih lama daripada yang anda rancangkan.

Dalam React, bahaya sering muncul pada sempadan antara komponen dan DOM. Anda mungkin memasang pendengar resize pada window di dalam modal, atau melanggan WebSocket dalam widget papan pemuka (dashboard). Apabila pengguna menutup modal atau beralih ke halaman lain, komponen tersebut akan di-unmount. Jika langganan tersebut masih bertahan, enjin akan melihat rujukan yang sah dari objek window global turun ke handler anda, dan dari handler anda kembali ke dalam closure komponen tersebut. Komponen, props, state, dan keseluruhan subpokok nod DOM kekal terpaku dalam memori. Melalui ratusan interaksi, objek-objek yang terpaku ini akan terkumpul. Penggunaan memori meningkat dalam corak gergaji (sawtooth pattern) yang tidak pernah turun sepenuhnya.

Masalah Papan Pemuka Perusahaan

Ini paling kerap berlaku dalam papan pemuka perusahaan di mana pengguna kekal pada satu halaman selama berjam-jam. Fikirkan tentang panel pemantauan, paparan analitik, atau sistem tiket. Seorang pengguna membuka modal butiran, menapis set data yang besar, atau menukar tab dalam aplikasi satu halaman (single-page app). Setiap interaksi individu terasa lancar. Walau bagaimanapun, lama-kelamaan, nod yatim (orphaned nodes) dan pendengar yang terputus (detached listeners) akan terkumpul. Aplikasi menjadi perlahan bukan disebabkan oleh satu render yang mahal, tetapi kerana heap berkembang cukup besar untuk mencetuskan jeda pengumpulan sampah yang kerap dan mahal.

Berhenti meneka tentang hook useEffect anda. Satu-satunya cara untuk mengetahui jika terdapat kebocoran adalah dengan mengukur heap secara langsung. Chrome DevTools memberikan anda keterlihatan tersebut.

Memburu Kebocoran dengan Chrome DevTools

Anda memerlukan urutan yang boleh dihasilkan semula (reproducible sequence) dan beberapa minit perhatian yang fokus. Buka aplikasi anda dalam Chrome, lancarkan DevTools, dan navigasi ke tab Memory.

Rekodkan garis dasar (baseline). Pilih Heap snapshot dan klik Take snapshot. Ini merakam setiap objek yang sedang wujud dalam JavaScript heap dan menunjukkan memori permulaan anda. Lakukan ini selepas halaman telah stabil dalam keadaan pegun (idle) awalnya, bukan semasa pemuatan awal, supaya anda hanya mengukur pertumbuhan yang disebabkan oleh tindakan pengguna.

Cetuskan tindakan. Lakukan interaksi UI yang tepat yang anda syaki menyebabkan kebocoran. Buka dan tutup modal, tukar carta yang kompleks, atau tukar laluan (route) dan navigasi kembali. Setelah selesai, kembalikan aplikasi ke keadaan visual asalnya. Langkah ini sangat penting. Anda mahu UI kelihatan serupa dengan rupa semasa garis dasar. Jika heap telah berkembang walaupun UI kelihatan kosong, anda mempunyai bukti kukuh tentang kebocoran.

Paksa pengumpulan sampah (garbage collection). Klik ikon tong sampah dalam tab Memory. Ini mencetuskan kitaran GC penuh dan membersihkan objek sementara yang secara sah bertahan sehingga pengumpulan seterusnya. Apa yang tinggal adalah kebocoran sebenar, iaitu objek yang sepatutnya telah dikumpul tetapi dikekalkan oleh rujukan yang tidak disengajakan.

Ambil tangkapan skrin (snapshot) kedua. Klik Take snapshot sekali lagi. Anda kini mempunyai dua "foto" heap yang diambil di bawah keadaan UI yang sama.

Bandingkan keputusan. Tukar paparan daripada Summary kepada Comparison. Tetapkan garis dasar kepada tangkapan pertama anda dan tangkapan yang dibandingkan kepada yang kedua. Paparan Comparison menyenaraikan setiap kategori objek dan menunjukkan delta kepada anda, iaitu perubahan bersih dalam jumlah objek antara dua tangkapan tersebut.

Susun mengikut Delta. Cari kategori yang meningkat secara ketara. Fokus terutamanya pada Detached HTMLElement dan nod React fiber. Elemen HTML yang terputus (detached) ialah nod DOM yang tidak lagi disambungkan ke pokok dokumen aktif, namun rujukan JavaScript tertentu masih memegangnya. Ini adalah bukti nyata (smoking guns). Ia tidak sepatutnya wujud selepas anda menutup modal atau melakukan unmount pada komponen.

Membaca Laluan Pengekalan (Retaining Path)

When you select a detached element in the snapshot, Chrome shows the retaining path in the bottom panel. This path is a chain of references from the root down to the selected object. Follow it carefully. You will often find an event listener, an IntersectionObserver, a setInterval ID, or a closure pointing to a specific line in your component.

Look for names you recognize. If you see a listener attached to window with a function name from your codebase, you have found the anchor. The object holding that listener is keeping your entire component alive. Sometimes the chain runs through a third-party library. In those cases, check whether the library expects an explicit teardown call that you forgot to invoke in a cleanup function.

Fixing the Root Causes

Once you identify the retaining path, the fix is usually mechanical but requires discipline across the team.

Use cleanup functions. Always return a cleanup function in useEffect when you add listeners to the window or document. If your effect subscribes to a resize event, remove that subscription before the component unmounts. The cleanup runs when React tears down the component, giving you a guaranteed hook to sever external connections.

Stabilize references. Wrap your handlers in useCallback. This ensures you pass the exact same function reference to removeEventListener that you originally passed to addEventListener. If you register an inline function like window.addEventListener('resize', () => { ... }) and later try to remove it with another inline function, the references will not match. The listener stays attached. The closure inside it keeps your component state alive. useCallback with a stable dependency array prevents this identity mismatch.

Sever global links. Remember that the browser's event target objects, like window and document, live for the lifetime of the page. Any reference from them into your component acts as a global anchor. Removing the listener breaks that anchor and lets the V8 engine sweep away the component state and DOM nodes during the next garbage collection cycle.

Consider a modal that tracks window width. Without cleanup, each time the user opens the modal, a new listener attaches. The old ones never detach because the component instances they belong to are gone, yet the functions themselves were anonymous and lost. Using a named handler wrapped in useCallback, plus a cleanup function that calls removeEventListener, closes the loop cleanly.

A Real Takeaway

Memory leaks in React rarely announce themselves with a clear error message. They announce themselves with a tab that grows heavier the longer it stays open. Do not waste time speculating about which hook is guilty. Open the Memory tab, force garbage collection, and compare snapshots. Let the heap profiler show you the exact retaining path. Then write the cleanup function, stabilize the callback reference, and cut the global link. Your users will not notice the fix directly, but they will notice that the dashboard still runs smoothly at the end of the day.