Effect yang menyalakan sesuatu yang terus jalan, misalnya timer, event listener, atau request ke server, juga harus bisa mematikannya. Caranya: effect me-return sebuah function, namanya cleanup function. React memanggilnya waktu komponen dilepas dari halaman, dan sebelum effect itu jalan lagi. Mirip keran air di dapur: kalau kamu pergi tanpa menutupnya, airnya terus mengalir walau gak ada lagi yang memakai. Cleanup adalah pesan "tutup kerannya" yang kamu titipkan ke React sejak keran dibuka.
🧪 Tebakan cepat: komponen
TimermemasangsetIntervaldiuseEffecttanpa cleanup. Kamu menyembunyikan lalu menampilkanTimerlagi, tiga kali. Sekarang ada berapa interval yang jalan: 1 atau 4? Tebak dulu, jawabannya ada di bagian "Cleanup: tutup yang kamu buka".
Cleanup: tutup yang kamu buka
Dua istilah dulu. Mount artinya komponen muncul di halaman. Unmount artinya komponen dilepas dari halaman, misalnya karena {tampil && <Timer />} berubah jadi false.
function Timer() {
const [seconds, setSeconds] = useState(0)
useEffect(() => {
const id = setInterval(() => {
setSeconds((s) => s + 1) // naik 1 tiap detik
console.log('tick')
}, 1000)
return () => clearInterval(id) // cleanup: hentikan interval ini
}, []) // [] = dipasang sekali, waktu Timer mount
return <span id="timer">{seconds}</span>
}
setInterval(fn, 1000)menjalankanfntiap 1.000 ms (1 detik) dan mengembalikan nomoridinterval itu. Interval jalan terus sampai dihentikan denganclearInterval(id).setSeconds((s) => s + 1)memakai updater function, jadi selalu menambah dari nilai terbaru. Alasannya ada di bagian "Stale closure".return () => clearInterval(id): function yang di-return effect adalah cleanup. React menyimpannya dan baru memanggilnya nanti. Yang di-return function-nya (() => ...), bukan hasilclearInterval(id).[]sebagai argumen kedua: effect dipasang sekali waktu mount, jadi cleanup-nya jalan sekali waktu unmount.- Di project asli, hook diimpor dulu:
import { useEffect, useState } from 'react'. Di editor latihan,useState,useEffect, danuseRefsudah tersedia. Baris import boleh ditulis, tapi gak wajib.
| Kejadian | Dengan cleanup | Tanpa cleanup |
|---|---|---|
Timer mount |
1 interval | 1 interval |
Timer unmount |
clearInterval, 0 interval |
1 interval, masih jalan |
| Mount lagi | 1 interval | 2 interval |
| Unmount dan mount lagi, 2 kali lagi | 1 interval | 4 interval |
Tanpa cleanup, tiap mount memasang interval baru dan yang lama gak pernah berhenti. Tiga kali tampil lagi berarti 4 interval jalan bareng, dan console dapat 4 tick per detik. Itu jawaban tebakannya. Angka di layar tetap naik 1 per detik (React mengabaikan setSeconds dari komponen yang sudah dilepas), jadi bug ini sering gak kelihatan. Namanya memory leak: sesuatu yang sudah gak dipakai tapi tetap makan memori dan tenaga.
Kapan cleanup jalan
React memanggil cleanup di dua saat:
- Waktu komponen unmount.
- Sebelum effect jalan lagi karena isi dependency array berubah. Cleanup milik render lama jalan dulu, baru setup yang baru.
Effect yang cuma mengganti sesuatu gak butuh cleanup. Contohnya judul tab:
function App() {
const [count, setCount] = useState(0)
useEffect(() => {
document.title = 'Count: ' + count // judul tab ikut angkanya
}, [count]) // jalan lagi tiap count berubah
return (
<div>
<span id="count">{count}</span>
<button id="add" onClick={() => setCount((c) => c + 1)}>+1</button>
</div>
)
}
Awalnya judul tab Count: 0. Sesudah 1 klik #add jadi Count: 1, sesudah 2 klik Count: 2. Tiap effect menimpa judul yang lama, jadi gak ada yang perlu dibersihkan. Cleanup cuma wajib untuk yang terus jalan: interval, listener, request, koneksi.
Satu larangan: jangan mengubah state yang ada di dependency array dari dalam effect itu sendiri. useEffect(() => { setCount(count + 1) }, [count]) mengubah count, count berubah jadi effect jalan lagi, lalu mengubah count lagi, terus tanpa henti. Itu infinite loop.
Event listener juga dilepas
useEffect(() => {
function onKey(e) {
if (e.key === 'Escape') setOpen(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
removeEventListener butuh function yang sama dengan yang dipasang, makanya onKey diberi nama. Dua arrow function yang ditulis terpisah itu dua function berbeda, dan listener-nya gak pernah lepas.
Race condition: jawaban lama datang belakangan
Race condition terjadi kalau dua request balapan dan yang menang adalah yang datang terakhir, bukan yang kamu pilih terakhir. Misalnya server butuh 1,5 detik untuk Andi dan 0,4 detik untuk Budi. Kamu pilih Andi, lalu setengah detik kemudian pilih Budi:
| Waktu | Kejadian | Tanpa penjaga | Dengan ignore |
|---|---|---|---|
| 0 s | pilih Andi, request Andi dikirim | ||
| 0,5 s | pilih Budi: cleanup effect Andi, request Budi dikirim | ignore Andi jadi true |
|
| 0,9 s | jawaban Budi datang | tampil Budi | tampil Budi |
| 1,5 s | jawaban Andi datang | tampil Andi, salah orang | dibuang |
function Profile({ userId }) {
const [user, setUser] = useState(null)
useEffect(() => {
let ignore = false // penjaga milik effect ini
fetchUser(userId).then((data) => {
if (!ignore) setUser(data) // jawaban basi dibuang
})
return () => {
ignore = true // userId berganti: effect ini sudah gak dipakai
}
}, [userId])
return <p>Profil: {user ? user.name : 'memuat...'}</p>
}
fetchUser(userId) mengambil data dari server, misalnya fetch('/api/users/' + userId).then((res) => res.json()). Tiap effect punya ignore sendiri. Begitu userId berganti, cleanup effect lama mengisi ignore miliknya dengan true, jadi jawabannya dibuang waktu datang.
Cara kedua, AbortController, benar-benar membatalkan request lama:
useEffect(() => {
const controller = new AbortController()
fetch('/api/users/' + userId, { signal: controller.signal })
.then((res) => res.json())
.then(setUser)
.catch((err) => {
if (err.name !== 'AbortError') console.error(err)
})
return () => controller.abort() // batalkan request yang lama
}, [userId])
controller.abort() membuat fetch gagal dengan error bernama AbortError. Error itu memang disengaja, jadi diabaikan di catch.
Stale closure: angka yang membeku
useEffect(() => {
const id = setInterval(() => {
setSeconds(seconds + 1) // seconds di sini selalu 0
}, 1000)
return () => clearInterval(id)
}, [])
Effect ini jalan sekali, di render pertama, waktu seconds masih 0. Function di dalam setInterval ingat seconds dari render itu (closure, kamu sudah kenal dari track JavaScript). Tiap detik ia menghitung 0 + 1, jadi angkanya membeku di 1. Nilai lama yang terus terbawa ini disebut stale closure. Obatnya updater function: setSeconds((s) => s + 1). React selalu memberi nilai terbaru sebagai s.
⚠️ Jebakan yang sering ketemu
return clearInterval(id)tanpa() =>.clearIntervallangsung jalan saat setup, jadi timer berhenti sebelum mulai, dan yang di-returnundefined. Tulisreturn () => clearInterval(id).- Lupa
[]. Effect tanpa dependency array jalan tiap render. Untuk timer yang dipasang sekali, tutup effect-nya dengan}, []). useEffect(async () => ...). Functionasyncselalu me-return Promise, padahal React menunggu cleanup function. Buat function async di dalam effect, lalu panggil.- Effect jalan dua kali di development. Dengan Strict Mode, React 18 ke atas sengaja menjalankan setup, cleanup, lalu setup lagi waktu development, untuk menguji cleanup-mu. Kalau cleanup-nya benar, hasilnya sama. Di production cuma sekali.
TL;DR: Function yang di-return effect adalah cleanup function. React menjalankannya waktu komponen unmount dan sebelum effect jalan lagi karena dependency-nya berubah. Yang terus jalan wajib dibersihkan: setInterval dengan return () => clearInterval(id), addEventListener dengan removeEventListener memakai function yang sama, request dengan penjaga ignore atau controller.abort() supaya jawaban lama gak menimpa yang baru (race condition). document.title = 'Count: ' + count dengan [count] gak butuh cleanup. Jangan ubah state yang ada di dependency array dari effect-nya sendiri, itu infinite loop. Di interval, tambah dengan setSeconds((s) => s + 1), bukan seconds + 1, supaya gak kena stale closure.