Kenapa ini penting
Di modul sebelumnya, kalau jaringan lambat, user melihat layar kosong dan tidak tahu apakah aplikasi sedang bekerja atau rusak. Kalau server mati, aplikasi diam saja tanpa pesan. Dua pengalaman ini termasuk penyebab utama user menutup aplikasi. Setiap pengambilan data punya tiga kemungkinan keadaan: sedang memuat, gagal, atau berhasil. Aplikasi yang baik menampilkan ketiganya dengan jelas, memberi jalan keluar (tombol coba lagi), dan tidak "meledak" seluruhnya hanya karena satu bagian bermasalah. Di sinilah peran state loading/error/data, skeleton, retry, dan Error Boundary.
Konsep inti
Tiga state: loading, error, data
Pola paling sederhana memakai tiga state terpisah:
| State | Nilai awal | Diubah ketika |
|---|---|---|
loading |
true |
Request mulai (true) dan selesai (false) |
error |
null |
Request gagal (isi dengan objek Error) |
data |
null / [] |
Request berhasil |
Lalu di JSX gunakan early return: kalau loading tampilkan skeleton, kalau error tampilkan pesan + tombol coba lagi, selain itu tampilkan data. Pola try / catch / finally di fungsi async sangat pas: try untuk sukses, catch untuk error, finally untuk mematikan loading apa pun hasilnya.
Alternatif yang lebih tegas adalah satu state status bernilai 'loading' | 'error' | 'success', yang mencegah kombinasi aneh seperti loading dan error sekaligus true. Kamu bisa juga memakai useReducer yang sudah kamu kenal.
Skeleton lebih baik dari spinner
Skeleton adalah kerangka abu-abu berbentuk mirip konten asli (kotak untuk gambar, garis untuk teks) yang tampil selama memuat. Dibanding spinner, skeleton memberi gambaran tata letak sehingga halaman tidak "melompat" saat data datang, dan terasa lebih cepat. Analogi: di restoran, piring dan alat makan disiapkan dulu sebelum makanan datang. Kamu tahu makanannya sedang dalam perjalanan.
Retry
Error jaringan sering bersifat sementara (sinyal hilang sebentar). Tombol Coba lagi cukup memicu effect untuk berjalan ulang. Trik yang mudah: simpan state percobaan (angka) dan masukkan ke dependency array. Setiap klik retry menaikkan angkanya, effect berjalan lagi.
Error Boundary
State error di atas menangani error dari fetch. Tapi bagaimana kalau error terjadi saat render, misalnya kamu menulis user.address.city padahal address tidak ada? Tanpa penanganan, React akan melepas (unmount) seluruh pohon komponen dan layar jadi putih.
Error Boundary adalah komponen yang menangkap error render dari komponen di bawahnya dan menampilkan UI cadangan (fallback). Per dokumentasi resmi React, saat ini belum ada cara menulis Error Boundary sebagai function component; ia ditulis sebagai class component dengan static getDerivedStateFromError (untuk mengubah state ke mode error) dan componentDidCatch (untuk mencatat error, misalnya ke layanan monitoring). Kabar baiknya, kamu cukup menulisnya sekali, atau memakai paket populer react-error-boundary (versi saat ini 6.1.6).
Error Boundary tidak menangkap error di event handler (misalnya di dalam onClick) dan error di kode async seperti setTimeout atau Promise yang tidak di-render. Untuk itu tetap pakai try/catch biasa dan state error.
Tempatkan Error Boundary di beberapa titik strategis, misalnya per widget di dashboard, sehingga satu widget rusak tidak menjatuhkan seluruh halaman.
Pesan error yang membantu user
Pesan seperti "TypeError: Failed to fetch" berguna untuk developer, tapi membingungkan user. Pisahkan keduanya: catat detail teknis ke console.error atau layanan monitoring, dan tampilkan pesan manusiawi ke user, misalnya "Koneksi terputus. Periksa internet kamu lalu coba lagi." Pesan yang baik menjelaskan apa yang terjadi dan apa yang bisa dilakukan. Tombol "Coba lagi" adalah tindakan paling umum, tapi untuk error 401 (belum login) tindakan yang tepat adalah mengarahkan ke halaman login.
Loading pertama vs memuat ulang
Ada perbedaan antara loading pertama (belum ada data sama sekali) dan memuat ulang (sudah ada data lama, sedang mengambil versi terbaru). Untuk loading pertama, skeleton cocok. Untuk memuat ulang, sering kali lebih baik tetap menampilkan data lama dengan indikator kecil (misalnya teks "Memperbarui..." atau list sedikit dipudarkan), daripada mengganti seluruh layar dengan skeleton lagi. Mengganti layar setiap refresh membuat halaman terasa "berkedip".
Aksesibilitas
Tambahkan role="alert" pada pesan error agar dibacakan pembaca layar, dan aria-busy="true" pada area yang sedang memuat. Atribut kecil ini membuat aplikasimu bisa dipakai lebih banyak orang.
Contoh kode
Komponen dengan tiga state, skeleton, dan retry:
// UserListLengkap.jsx
import { useEffect, useState } from 'react';
function SkeletonRow() {
return (
<li style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<div style={{ width: 40, height: 40, borderRadius: '50%', background: '#e5e7eb' }} />
<div style={{ flex: 1, height: 16, background: '#e5e7eb', borderRadius: 4, marginTop: 12 }} />
</li>
);
}
export function UserListLengkap() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [percobaan, setPercobaan] = useState(0); // dinaikkan untuk retry
useEffect(() => {
let ignore = false;
async function load() {
setLoading(true);
setError(null);
try {
const res = await fetch('https://jsonplaceholder.typicode.com/users');
if (!res.ok) throw new Error(`Server membalas ${res.status}`);
const json = await res.json();
if (!ignore) setData(json);
} catch (err) {
if (!ignore) setError(err);
} finally {
if (!ignore) setLoading(false);
}
}
load();
return () => {
ignore = true;
};
}, [percobaan]); // berubah → fetch ulang
if (loading) {
return (
<ul aria-busy="true">
{Array.from({ length: 5 }, (_, i) => (
<SkeletonRow key={i} />
))}
</ul>
);
}
if (error) {
return (
<div role="alert">
<p>Gagal memuat data: {error.message}</p>
<button onClick={() => setPercobaan((n) => n + 1)}>Coba lagi</button>
</div>
);
}
if (data.length === 0) return <p>Belum ada user.</p>; // empty state juga penting
return (
<ul>
{data.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
// Matikan internet lalu refresh → "Gagal memuat data: Failed to fetch" + tombol Coba lagi
// Nyalakan lagi, klik Coba lagi → skeleton sebentar, lalu 10 nama user
Error Boundary buatan sendiri:
// ErrorBoundary.jsx
import { Component } from 'react';
export class ErrorBoundary extends Component {
state = { error: null };
// dipanggil saat anak melempar error ketika render → ubah ke mode error
static getDerivedStateFromError(error) {
return { error };
}
// tempat mencatat error (misalnya kirim ke layanan monitoring)
componentDidCatch(error, info) {
console.error('Tertangkap Error Boundary:', error, info.componentStack);
}
reset = () => this.setState({ error: null });
render() {
if (this.state.error) {
return (
<div role="alert">
<p>Bagian ini sedang bermasalah.</p>
<button onClick={this.reset}>Muat ulang bagian ini</button>
</div>
);
}
return this.props.children;
}
}
// Dashboard.jsx
import { ErrorBoundary } from './ErrorBoundary.jsx';
function WidgetRusak() {
const user = {}; // address tidak ada
return <p>{user.address.city}</p>; // melempar TypeError saat render
}
function WidgetSehat() {
return <p>Penjualan hari ini: 42</p>;
}
export default function Dashboard() {
return (
<>
<ErrorBoundary>
<WidgetRusak />
</ErrorBoundary>
<ErrorBoundary>
<WidgetSehat />
</ErrorBoundary>
</>
);
}
// → "Bagian ini sedang bermasalah." + "Penjualan hari ini: 42"
// widget sehat tetap tampil walaupun widget lain rusak
Versi dengan paket react-error-boundary:
// npm install react-error-boundary
import { ErrorBoundary } from 'react-error-boundary';
<ErrorBoundary fallback={<p>Ada yang salah di widget ini.</p>}>
<WidgetRusak />
</ErrorBoundary>
Kesalahan umum
- Lupa mematikan loading saat error. Kalau
setLoading(false)hanya di blok sukses, spinner berputar selamanya saat gagal. Taruh difinally. - Menampilkan objek error langsung di JSX.
{error}akan error karena objek tidak bisa dirender. Pakai{error.message}. - Mengandalkan Error Boundary untuk error fetch atau onClick. Error Boundary hanya menangkap error saat render, lifecycle, dan effect komponen anak. Error di event handler dan async butuh
try/catch. - Satu Error Boundary di paling atas saja. Satu bagian kecil error, seluruh aplikasi diganti fallback. Pasang juga per bagian penting.
- Melupakan empty state. Data berhasil dimuat tapi kosong (array
[]) seharusnya punya pesan sendiri, bukan list kosong tanpa penjelasan.
Latihan
- Mudah, Tambahkan state
loadingdanerrorke komponenPostListdari modul sebelumnya, dan tampilkan teks "Memuat..." serta pesan error. - Sedang, Ganti
loading,error,datadiUserListLengkapdengan satuuseReduceryang punya status'loading' | 'error' | 'success'. - Menantang, Buat Error Boundary yang menerima props
fallbackberupa fungsi({ error, reset }) => JSX, sehingga tiap pemakai bisa menentukan tampilan error sendiri.
Lihat solusi
// 1.
import { useEffect, useState } from 'react';
export function PostList() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
async function load() {
try {
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5');
if (!res.ok) throw new Error(`Status ${res.status}`);
const data = await res.json();
if (!ignore) setPosts(data);
} catch (err) {
if (!ignore) setError(err);
} finally {
if (!ignore) setLoading(false);
}
}
load();
return () => { ignore = true; };
}, []);
if (loading) return <p>Memuat...</p>;
if (error) return <p>Error: {error.message}</p>;
return <ol>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ol>;
}
// 2.
import { useEffect, useReducer, useState } from 'react';
function reducer(state, action) {
switch (action.type) {
case 'mulai':
return { status: 'loading', data: state.data, error: null };
case 'sukses':
return { status: 'success', data: action.data, error: null };
case 'gagal':
return { status: 'error', data: state.data, error: action.error };
default:
return state;
}
}
export function UserListReducer() {
const [state, dispatch] = useReducer(reducer, { status: 'loading', data: [], error: null });
const [percobaan, setPercobaan] = useState(0);
useEffect(() => {
let ignore = false;
dispatch({ type: 'mulai' });
fetch('https://jsonplaceholder.typicode.com/users')
.then((res) => {
if (!res.ok) throw new Error(`Status ${res.status}`);
return res.json();
})
.then((data) => { if (!ignore) dispatch({ type: 'sukses', data }); })
.catch((error) => { if (!ignore) dispatch({ type: 'gagal', error }); });
return () => { ignore = true; };
}, [percobaan]);
if (state.status === 'loading') return <p>Memuat...</p>;
if (state.status === 'error')
return <button onClick={() => setPercobaan((n) => n + 1)}>Gagal, coba lagi</button>;
return <ul>{state.data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
// 3.
import { Component } from 'react';
export class ErrorBoundary extends Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error };
}
reset = () => this.setState({ error: null });
render() {
if (this.state.error) {
return this.props.fallback({ error: this.state.error, reset: this.reset });
}
return this.props.children;
}
}
// Pemakaian
<ErrorBoundary
fallback={({ error, reset }) => (
<div>
<p>Oops: {error.message}</p>
<button onClick={reset}>Coba lagi</button>
</div>
)}
>
<WidgetRusak />
</ErrorBoundary>
Ringkasan
- Setiap pengambilan data punya tiga keadaan: loading, error, dan sukses (plus empty state).
- Pola
try/catch/finallymemastikan loading selalu dimatikan apa pun hasilnya. - Skeleton memberi gambaran tata letak dan terasa lebih cepat dibanding layar kosong.
- Retry cukup dengan menaikkan angka state yang masuk dependency array effect.
- Error Boundary (masih berupa class component) menangkap error render dan mencegah seluruh aplikasi menjadi layar putih.