kodingup.camp

Beranda/React Mastery: Styling, Pola Komponen, Data & Optimasi

Menangani Loading dan Kegagalan

Modul 9 dari 135 menit bacaLoading & Failure State Handling

Isi modul

    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

    1. Lupa mematikan loading saat error. Kalau setLoading(false) hanya di blok sukses, spinner berputar selamanya saat gagal. Taruh di finally.
    2. Menampilkan objek error langsung di JSX. {error} akan error karena objek tidak bisa dirender. Pakai {error.message}.
    3. 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.
    4. Satu Error Boundary di paling atas saja. Satu bagian kecil error, seluruh aplikasi diganti fallback. Pasang juga per bagian penting.
    5. Melupakan empty state. Data berhasil dimuat tapi kosong (array []) seharusnya punya pesan sendiri, bukan list kosong tanpa penjelasan.

    Latihan

    1. Mudah, Tambahkan state loading dan error ke komponen PostList dari modul sebelumnya, dan tampilkan teks "Memuat..." serta pesan error.
    2. Sedang, Ganti loading, error, data di UserListLengkap dengan satu useReducer yang punya status 'loading' | 'error' | 'success'.
    3. Menantang, Buat Error Boundary yang menerima props fallback berupa 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

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.