Kenapa ini penting
Begitu komponenmu mulai melakukan lebih dari satu hal di luar React, misalnya mengambil data berdasarkan pilihan pengguna sekaligus menyimpan preferensi dan memasang listener keyboard, kamu akan berhadapan dengan tiga masalah klasik: effect yang saling bercampur sehingga sulit dibaca, race condition (respons lama menimpa respons baru), dan infinite loop (effect memicu render, render memicu effect, tanpa henti). Ketiganya sering lolos saat dicoba sekilas tapi muncul di jaringan lambat atau di tangan pengguna yang mengklik cepat. Modul ini membekalimu pola yang benar untuk menghindarinya.
Konsep inti
Satu effect, satu tujuan
Kamu boleh memanggil useEffect berkali-kali dalam satu komponen, dan sebaiknya memang begitu: pisahkan effect berdasarkan tujuan sinkronisasinya, bukan berdasarkan "kapan dijalankan". Setiap effect punya dependensi dan cleanup sendiri, sehingga perubahan satu hal tidak memicu ulang hal lain yang tidak berkaitan. React menjalankan effect sesuai urutan penulisannya.
Analogi: dapur restoran punya stasiun terpisah untuk goreng, kukus, dan minuman. Kalau semua digabung di satu kompor, pesanan es teh pun harus menunggu ayam goreng matang.
Race condition pada fetch
Skenario: pengguna memilih "Alice", lalu cepat-cepat memilih "Bob". Dua request terkirim. Kalau respons Alice ternyata datang lebih lambat dari Bob, tanpa perlindungan, data Alice akan menimpa data Bob, padahal yang dipilih Bob. Layar menampilkan data yang salah.
Dua cara melindungi diri (keduanya dijelaskan di dokumentasi react.dev):
- Flag
ignore: setiap effect punya variabelignoresendiri. Cleanup mengubahnya jaditrue. Respons dari effect lama dicek dan diabaikan. Dokumentasi React menyebut flag eksplisit ini sebagai cara paling andal, karena tetap aman walaupun ada langkah async lain setelah fetch. AbortController: membatalkan request yang sudah tidak dibutuhkan, sehingga browser benar-benar berhenti mengunduh. Hemat bandwidth. Saat dibatalkan,fetchmelempar error bernamaAbortErroryang harus diabaikan (bukan ditampilkan sebagai error ke pengguna).
Dalam praktik, keduanya sering digabung: AbortController untuk menghemat jaringan, dan pemeriksaan signal.aborted atau flag ignore sebelum setState.
| Teknik | Mencegah data lama tampil | Membatalkan unduhan | Catatan |
|---|---|---|---|
Flag ignore |
✅ | ❌ | Paling sederhana dan andal |
AbortController |
✅ (jika error abort ditangani) | ✅ | Tangani AbortError |
| Gabungan | ✅ | ✅ | Pola yang direkomendasikan untuk produksi |
Menghindari infinite loop
Penyebab paling umum:
- Effect mengubah state yang menjadi dependensinya sendiri.
useEffect(() => setCount(count + 1), [count])→ render → effect → setCount → render → ... tanpa akhir. - Tidak ada dependency array dan effect memanggil setState. Effect berjalan setiap render, setiap kali mengubah state, memicu render lagi.
- Dependensi berupa objek/array/fungsi yang dibuat ulang setiap render.
const opsi = { kota }di badan komponen selalu menjadi objek baru, sehinggaObject.isselalu menganggapnya berubah dan effect berjalan setiap render. Kalau effect itu juga memanggil setState, terjadilah loop.
Solusi:
- Pindahkan objek/fungsi yang hanya dipakai effect ke dalam effect, lalu jadikan nilai primitifnya (string, angka) sebagai dependensi.
- Kalau objek/fungsi tidak bergantung pada props/state, pindahkan ke luar komponen.
- Pakai updater function (
setCount(c => c + 1)) supaya state tidak perlu dicantumkan sebagai dependensi. - Pertanyakan apakah effect itu perlu sama sekali (lihat modul sebelumnya).
Contoh kode
// src/App.jsx, profil user GitHub dengan dua effect terpisah dan perlindungan race condition
import { useEffect, useState } from 'react';
const USERNAMES = ['torvalds', 'gaearon', 'yyx990803'];
export default function ProfilGithub() {
const [username, setUsername] = useState(USERNAMES[0]);
const [profil, setProfil] = useState(null);
const [error, setError] = useState(null);
const [tema, setTema] = useState('terang');
// Effect 1: fetch profil, bergantung pada username
useEffect(() => {
const controller = new AbortController();
let ignore = false;
setProfil(null);
setError(null);
async function ambil() {
try {
const res = await fetch(`https://api.github.com/users/${username}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (!ignore) setProfil(data);
} catch (err) {
if (err.name === 'AbortError') return; // dibatalkan sengaja, bukan error
if (!ignore) setError(err.message);
}
}
ambil();
return () => {
ignore = true; // abaikan hasil effect lama
controller.abort(); // hentikan unduhan yang tidak diperlukan
};
}, [username]);
// Effect 2: sinkronkan tema ke <body>, terpisah, punya dependensi sendiri
useEffect(() => {
document.body.style.background = tema === 'gelap' ? '#0f172a' : '#ffffff';
document.body.style.color = tema === 'gelap' ? '#e2e8f0' : '#0f172a';
}, [tema]);
return (
<main>
<select value={username} onChange={e => setUsername(e.target.value)}>
{USERNAMES.map(u => <option key={u} value={u}>{u}</option>)}
</select>
<button onClick={() => setTema(t => (t === 'terang' ? 'gelap' : 'terang'))}>Tema: {tema}</button>
{error && <p>Gagal memuat: {error}</p>}
{!error && !profil && <p>Memuat {username}...</p>}
{profil && (
<section>
<img src={profil.avatar_url} alt={profil.login} width={80} />
<h2>{profil.name ?? profil.login}</h2>
<p>Repositori publik: {profil.public_repos}</p>
</section>
)}
</main>
);
}
// Ganti tema → effect fetch TIDAK berjalan ulang (dependensinya hanya username)
// Ganti username cepat-cepat → hanya profil terakhir yang tampil
Simulasi race condition agar terlihat jelas (tanpa jaringan):
import { useEffect, useState } from 'react';
// Bob sengaja dibuat lambat (2 detik), yang lain cepat (200 ms)
function ambilBio(nama) {
const jeda = nama === 'Bob' ? 2000 : 200;
return new Promise(resolve => setTimeout(() => resolve(`Ini bio milik ${nama}.`), jeda));
}
export function DemoRace() {
const [nama, setNama] = useState('Alice');
const [bio, setBio] = useState(null);
useEffect(() => {
let ignore = false;
setBio(null);
ambilBio(nama).then(hasil => {
if (!ignore) setBio(hasil);
});
return () => {
ignore = true;
};
}, [nama]);
return (
<>
<select value={nama} onChange={e => setNama(e.target.value)}>
<option>Alice</option>
<option>Bob</option>
<option>Taylor</option>
</select>
<p>{bio ?? 'Memuat...'}</p>
</>
);
}
// Pilih Bob lalu segera Taylor:
// tanpa ignore → "Ini bio milik Taylor." lalu 2 detik kemudian tertimpa "Ini bio milik Bob." (SALAH)
// dengan ignore → tetap "Ini bio milik Taylor." (BENAR)
Infinite loop dan perbaikannya:
import { useEffect, useState } from 'react';
export function CariCuaca({ kota }) {
const [data, setData] = useState(null);
// ❌ LOOP: 'opsi' objek baru setiap render → effect jalan setiap render → setData → render lagi
// const opsi = { kota, satuan: 'metric' };
// useEffect(() => { ambil(opsi).then(setData); }, [opsi]);
// ✅ Buat objek DI DALAM effect, dependensi cukup string 'kota'
useEffect(() => {
let ignore = false;
const opsi = { kota, satuan: 'metric' };
Promise.resolve({ ...opsi, suhu: 30 }).then(hasil => {
if (!ignore) setData(hasil);
});
return () => {
ignore = true;
};
}, [kota]);
return <p>{data ? `${data.kota}: ${data.suhu}°C` : 'Memuat...'}</p>;
}
// <CariCuaca kota="Bali" /> → "Bali: 30°C", effect berjalan hanya saat kota berubah
Kesalahan umum
- Menggabungkan beberapa tujuan dalam satu effect. Mengganti tema ikut memicu fetch ulang. Pisahkan menjadi beberapa effect dengan dependensi masing-masing.
- Tidak melindungi fetch dari race condition. Data lama menimpa data baru di jaringan lambat. Pakai flag
ignoredan/atauAbortController. - Menampilkan
AbortErrorsebagai pesan error. Pembatalan yang disengaja bukan kegagalan. Cekerr.name === 'AbortError'dan abaikan. - Objek/fungsi sebagai dependensi. Dibuat ulang setiap render sehingga effect selalu berjalan. Pindahkan ke dalam effect atau ke luar komponen.
- Effect mengubah state yang menjadi dependensinya. Menyebabkan infinite loop. Pakai updater, hitung saat render, atau pikirkan ulang alurnya.
Latihan
- Mudah, Komponen ini menyebabkan infinite loop. Perbaiki tanpa menghapus fitur menghitung berapa kali tombol diklik:
useEffect(() => { setKlik(klik + 1); });(tujuan aslinya menghitung klik). - Sedang, Buat pencarian user di
https://jsonplaceholder.typicode.com/users?username=...dari input teks, denganAbortControllersehingga setiap ketikan baru membatalkan request sebelumnya. - Menantang, Buat komponen dengan tiga effect terpisah: (a) fetch todo berdasarkan
userIddari select, dengan flagignore; (b) menyimpanuserIdterakhir kelocalStorage; (c) listener tombol keyboard panah kanan/kiri untuk menggantiuserId. Pastikan masing-masing punya dependensi dan cleanup yang benar.
Lihat solusi
1. Menghitung klik adalah respons aksi pengguna, jadi tempatnya di event handler, bukan effect:
import { useState } from 'react';
export default function HitungKlik() {
const [klik, setKlik] = useState(0);
return <button onClick={() => setKlik(k => k + 1)}>Diklik {klik} kali</button>;
}
2.
import { useEffect, useState } from 'react';
export default function CariUser() {
const [kata, setKata] = useState('');
const [hasil, setHasil] = useState([]);
useEffect(() => {
if (!kata) {
setHasil([]);
return;
}
const controller = new AbortController();
fetch(`https://jsonplaceholder.typicode.com/users?username=${encodeURIComponent(kata)}`, {
signal: controller.signal,
})
.then(res => res.json())
.then(data => setHasil(data))
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
return () => controller.abort(); // ketikan baru → request lama dibatalkan
}, [kata]);
return (
<>
<input value={kata} onChange={e => setKata(e.target.value)} placeholder="Coba: Bret" />
<ul>{hasil.map(u => <li key={u.id}>{u.name}</li>)}</ul>
</>
);
}
// Ketik "Bret" → "Leanne Graham"
3.
import { useEffect, useState } from 'react';
function bacaUserIdAwal() {
try {
return Number(localStorage.getItem('userId')) || 1;
} catch {
return 1;
}
}
export default function TodoPerUser() {
const [userId, setUserId] = useState(bacaUserIdAwal);
const [todos, setTodos] = useState([]);
// (a) fetch berdasarkan userId
useEffect(() => {
let ignore = false;
setTodos([]);
fetch(`https://jsonplaceholder.typicode.com/todos?userId=${userId}&_limit=5`)
.then(res => res.json())
.then(data => {
if (!ignore) setTodos(data);
});
return () => {
ignore = true;
};
}, [userId]);
// (b) simpan userId terakhir
useEffect(() => {
try {
localStorage.setItem('userId', String(userId));
} catch {
// abaikan jika storage diblokir
}
}, [userId]);
// (c) listener keyboard, dipasang sekali, memakai updater agar tidak butuh userId
useEffect(() => {
function handleKey(e) {
if (e.key === 'ArrowRight') setUserId(id => Math.min(id + 1, 10));
if (e.key === 'ArrowLeft') setUserId(id => Math.max(id - 1, 1));
}
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, []);
return (
<>
<select value={userId} onChange={e => setUserId(Number(e.target.value))}>
{Array.from({ length: 10 }, (_, i) => i + 1).map(n => (
<option key={n} value={n}>User {n}</option>
))}
</select>
<p>Tekan ← / → untuk ganti user</p>
<ul>{todos.map(t => <li key={t.id}>{t.completed ? '✅' : '⬜'} {t.title}</li>)}</ul>
</>
);
}
useState(bacaUserIdAwal) mengirim fungsi (lazy initializer) sehingga localStorage hanya dibaca sekali pada render pertama.
Ringkasan
- Pisahkan effect berdasarkan tujuan; setiap effect punya dependensi dan cleanup sendiri.
- Race condition terjadi saat respons lama datang belakangan dan menimpa data baru; lindungi dengan flag
ignore(paling andal) danAbortController(hemat jaringan). - Abaikan
AbortErrorkarena itu pembatalan yang disengaja. - Infinite loop biasanya karena effect mengubah dependensinya sendiri atau dependensi berupa objek/fungsi baru setiap render.
- Pindahkan objek ke dalam effect, pakai dependensi primitif, dan gunakan updater function untuk memutus loop.