Data dari server gak langsung ada. Komponen tampil dulu, permintaan (request) dikirim ke server, lalu jawabannya datang beberapa saat kemudian, atau malah gagal. Mirip pesan makanan lewat aplikasi: setelah pesan, layarnya bilang "pesanan diproses", lalu makanannya datang, atau muncul "pesanan gagal". Komponen yang mengambil data juga harus siap dengan tiga keadaan itu: loading (menunggu), data (berhasil), dan error (gagal).
🧪 Tebakan cepat: kamu klik Andi, jawabannya lambat (2,4 detik). Sebelum datang, kamu klik Budi, jawabannya cepat (0,8 detik). Kalau kodenya gak dijaga, nama siapa yang tampil di akhir? Tebak dulu, jawabannya ada di bagian "Ganti user dengan cepat".
Tiga keadaan: loading, error, data
Komponen yang mengambil data biasanya menyimpan tiga state:
| State | Nilai awal | Artinya |
|---|---|---|
loading |
true |
request masih jalan, jawabannya belum datang |
error |
null |
pesan kalau gagal, null kalau gak ada masalah |
user |
null |
datanya, terisi setelah jawaban datang |
loading dimulai dari true, karena di render pertama datanya memang belum ada. Kalau dimulai dari false, komponen langsung mencoba menampilkan user.name padahal user masih null, dan halamannya error.
Ambil data di useEffect
Mengambil data adalah side effect: pekerjaan yang menyentuh dunia di luar komponen. Tempatnya di useEffect, bukan langsung di badan komponen. Badan komponen jalan di tiap render, jadi fetch di sana berarti request baru di tiap render.
function UserCard({ userId }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
setLoading(true)
setError(null)
async function load() {
try {
const res = await fetch(`/api/users/${userId}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setUser(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
load()
}, [userId])
if (loading) return <p>Memuat...</p>
if (error) return <p>Gagal memuat: {error}</p>
return <p>{user.name}, {user.city}</p>
}
useEffect(..., [userId])jalan setelah render pertama, lalu jalan lagi tiapuserIdberubah. Dengan[], effect-nya cuma jalan sekali setelah komponen tampil.async function load()dibuat di dalam effect lalu langsung dipanggil. Alasannya ada di bagian berikutnya.res.okbernilaifalsekalau server menjawab 404 atau 500.fetchgak menganggap itu gagal, jadi kitathrowsendiri supaya masuk kecatch.res.json()membaca isi jawaban jadi object. Kalau pakai.then, bentuknya.then(res => res.json()).finallyjalan baik sukses maupun gagal, jadiloadingpasti kembalifalse.- Tiga baris terakhir memilih yang tampil: loading dulu, lalu error, baru data.
Ini urutannya di Lab untuk userId = 2, dengan jaringan normal (500 ms):
| Waktu | Yang terjadi | loading |
error |
user |
Tampil |
|---|---|---|---|---|---|
| 0 ms | render pertama | true |
null |
null |
Memuat... |
| 0 ms | effect jalan, request ke /api/users/2 dikirim |
true |
null |
null |
Memuat... |
| 500 ms | jawaban datang, setUser lalu setLoading(false) |
false |
null |
Budi | Budi, Bandung |
Nyalakan "Server error" di Lab: server menjawab 500, res.ok jadi false, kodenya melempar error, dan yang tampil Gagal memuat: HTTP 500.
Kenapa callback useEffect gak boleh async
// ❌ salah
useEffect(async () => {
const data = await fetchData()
setData(data)
}, [])
Function async selalu mengembalikan Promise. Padahal callback useEffect cuma boleh mengembalikan dua hal: tidak ada apa-apa, atau function cleanup. React akan menganggap Promise itu cleanup dan memberi peringatan. Jadi buat function async di dalam effect, lalu panggil, seperti load() tadi.
Ganti user dengan cepat
Masalah ini sudah kamu kenal di materi useEffect lanjutan: jawaban request lama yang datang telat bisa menimpa yang baru. Klik Andi (2,4 detik), lalu Budi (0,8 detik):
| Waktu | Kejadian | Tampil |
|---|---|---|
| 0 s | klik Andi, request ke /api/users/1 dikirim |
Memuat... |
| 0,2 s | klik Budi, request ke /api/users/2 dikirim |
Memuat... |
| 1,0 s | jawaban Budi datang | Budi, Bandung |
| 2,4 s | jawaban Andi datang telat, setUser jalan |
Andi, Jakarta |
Yang dipilih Budi, yang tampil Andi. Itu jawaban tebakannya. Penjaganya satu variabel ignore dan cleanup:
useEffect(() => {
let ignore = false
setLoading(true)
setError(null)
async function load() {
try {
const res = await fetch(`/api/users/${userId}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (!ignore) setUser(data)
} catch (err) {
if (!ignore) setError(err.message)
} finally {
if (!ignore) setLoading(false)
}
}
load()
return () => { ignore = true }
}, [userId])
- Saat
userIdganti, React menjalankan cleanup dari effect yang lama dulu, jadiignoremilik request Andi menjaditrue. - Jawaban Andi tetap datang, tapi tiap
set...dilewati. Yang tampil tetap Budi. AbortControlleradalah cara lain: request lamanya benar-benar dibatalkan, bukan cuma diabaikan.
📌 Di app sungguhan: TanStack Query
Pola tadi wajib kamu pahami, tapi menulisnya di tiap komponen melelahkan, dan masih banyak yang belum diurus: cache (data yang sudah diambil disimpan, jadi gak perlu minta lagi), coba ulang kalau gagal, ambil ulang saat tab dibuka lagi. Di app sungguhan, orang biasanya memakai library data seperti TanStack Query. BelajarWeb.dev juga memakainya.
async function getUser(userId) {
const res = await fetch(`/api/users/${userId}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
function UserCard({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => getUser(userId),
})
if (isLoading) return <p>Memuat...</p>
if (error) return <p>Gagal memuat: {error.message}</p>
return <p>{user.name}, {user.city}</p>
}
Isinya tetap tiga keadaan yang sama, cuma useEffect, ignore dan tiga useState-nya diurus library. queryKey adalah nama data itu di cache, dan library-nya cuma menampilkan jawaban untuk key yang sedang dipakai, jadi masalah jawaban telat tadi ikut beres. Supaya jalan, app-nya dibungkus QueryClientProvider sekali di bagian paling atas.
⚠️ Jebakan yang sering ketemu
- Fetch langsung di badan komponen. Tiap render mengirim request, jawabannya memanggil
setState,setStatememicu render, lalu fetch lagi: loop tanpa akhir. Taruh diuseEffect. - Lupa dependency array.
useEffect(() => { ... })tanpa array jalan setelah tiap render, sama saja dengan fetch di badan komponen. Pakai[]untuk sekali, atau[userId]untuk tiap id berubah. - Lupa cek
res.ok. Jawaban 404 ikut diproses seolah datanya ada. - Mengisi data sebagai nilai awal state.
useState(["Andi", "Budi"])gak mengambil apa-apa. Data dari server baru ada setelah request selesai, jadi isi state-nya lewatsetUsers(...)di dalam effect.
TL;DR: Data dari server butuh tiga state: loading (mulai true), error (mulai null), dan datanya. Ambil di useEffect, dengan [] untuk sekali atau [userId] untuk tiap id berubah. Callback useEffect gak boleh async: buat function async di dalamnya lalu panggil. Cek res.ok karena 404 dan 500 gak dianggap gagal oleh fetch, baca isinya dengan res.json(), dan matikan loading di finally. Kalau id bisa berganti cepat, pasang let ignore = false plus cleanup ignore = true supaya jawaban telat gak menimpa. Di app sungguhan, library seperti TanStack Query mengurus semua ini plus cache.