kodingup.camp

Beranda/React Mastery: Dasar, Rendering & Hooks

Manajemen Side Effect

Modul 9 dari 124 menit bacaSide Effect Management

Isi modul

    Kenapa ini penting

    Sejauh ini komponen kita murni: menerima props dan state, lalu mengembalikan JSX. Tapi aplikasi nyata harus "berbicara" dengan dunia di luar React: mengambil data dari API, mengubah judul tab browser, memasang timer, mendengarkan ukuran layar, atau menyambung ke server chat. Pekerjaan semacam ini disebut side effect (efek samping) karena terjadi di luar proses menghitung tampilan. Tempatnya bukan di badan komponen, melainkan di dalam hook useEffect. Sama pentingnya: tahu kapan tidak perlu effect, karena effect yang berlebihan adalah sumber bug dan render boros paling umum di proyek React.

    Konsep inti

    Apa itu useEffect

    useEffect(() => {
      // kode efek: berjalan SETELAH React menampilkan hasil render ke layar
      return () => {
        // cleanup (opsional): membersihkan efek sebelumnya
      };
    }, [dependensi]);
    

    Dokumentasi react.dev menggambarkan effect sebagai cara menyinkronkan komponen dengan sistem eksternal. Analogi: AC dengan termostat. Kamu menyetel suhu (state); AC (effect) menyesuaikan diri dengan kondisi ruangan. Kalau setelan berubah, AC berhenti menyesuaikan setelan lama dan mulai menyesuaikan setelan baru.

    Dependency array: kapan effect berjalan

    Bentuk Kapan berjalan
    useEffect(fn) tanpa array Setelah setiap render
    useEffect(fn, []) Hanya setelah render pertama (saat komponen muncul/mount)
    useEffect(fn, [a, b]) Setelah render pertama, lalu setiap kali a atau b berubah

    Aturan penting: semua nilai reaktif (props, state, dan variabel/fungsi yang dihitung di dalam komponen) yang dipakai di dalam effect harus dicantumkan di dependency array. Kamu tidak "memilih" dependensi; kode effect-mu yang menentukannya. Linter React akan memperingatkan kalau ada yang terlewat.

    React membandingkan setiap dependensi dengan nilai sebelumnya memakai Object.is. Objek dan array yang dibuat ulang setiap render selalu dianggap "berubah", hal yang akan kita bahas di modul berikutnya.

    Cleanup

    Fungsi yang di-return dari effect adalah cleanup. React memanggilnya:

    1. Sebelum menjalankan effect berikutnya (ketika dependensi berubah).
    2. Ketika komponen dilepas dari layar (unmount).

    Cleanup wajib untuk effect yang "memasang" sesuatu: setInterval → clearInterval, addEventListener → removeEventListener, koneksi → putus koneksi. Tanpa cleanup, timer dan listener terus menumpuk (memory leak).

    Ingat StrictMode: di development, React menjalankan effect → cleanup → effect lagi sekali saat mount. Kalau aplikasimu berperilaku aneh karena itu (misalnya interval berjalan dobel), artinya cleanup-mu belum benar. Itulah gunanya pemeriksaan tersebut.

    Fetch di dalam effect

    Mengambil data saat komponen muncul adalah penggunaan effect yang umum. Polanya: buat fungsi async di dalam effect lalu panggil, karena fungsi effect sendiri tidak boleh async (ia harus mengembalikan cleanup atau tidak sama sekali, bukan Promise). Kelola tiga keadaan: loading, error, dan data. Perlindungan dari race condition akan dibahas tuntas di modul berikutnya; di sini kita sudah memasang flag ignore sesuai pola di dokumentasi react.dev.

    Catatan dari dokumentasi React: untuk aplikasi produksi, fetching langsung di effect punya keterbatasan (tidak ada cache, rawan "waterfall"). Framework seperti Next.js atau library seperti TanStack Query menyelesaikannya. Tapi memahami pola dasarnya tetap wajib.

    Kapan TIDAK perlu effect

    Dokumentasi react.dev punya halaman khusus "You Might Not Need an Effect". Intinya:

    Situasi ❌ Pakai effect ✅ Cara yang benar
    Menghitung nilai dari state/props (nama lengkap, total harga, hasil filter) useEffect(() => setTotal(...), [items]) Hitung langsung saat render: const total = items.reduce(...)
    Merespons aksi pengguna (klik beli → kirim ke API) Set state "harusKirim" lalu effect mengirim Kirim langsung di event handler
    Reset state saat prop berubah Effect yang memanggil setX('') Beri komponen key yang berbeda

    Pertanyaan kunci: "Apakah kode ini berjalan karena komponen tampil, atau karena pengguna melakukan sesuatu?" Kalau karena aksi pengguna, tempatnya di event handler.

    Contoh kode

    // src/App.jsx, judul tab, timer dengan cleanup, listener ukuran layar, fetch data
    import { useEffect, useState } from 'react';
    
    function JudulTab({ jumlahPesan }) {
      useEffect(() => {
        // Sinkronkan judul tab dengan state
        document.title = jumlahPesan > 0 ? `(${jumlahPesan}) Kotak Masuk` : 'Kotak Masuk';
      }, [jumlahPesan]); // berjalan ulang setiap jumlahPesan berubah
      return <p>Pesan belum dibaca: {jumlahPesan}</p>;
    }
    
    function Stopwatch() {
      const [detik, setDetik] = useState(0);
      useEffect(() => {
        const id = setInterval(() => {
          setDetik(d => d + 1); // updater: tidak perlu detik di dependensi
        }, 1000);
        return () => clearInterval(id); // cleanup: hentikan timer saat unmount
      }, []);
      return <p>⏱️ {detik} detik</p>;
    }
    
    function LebarLayar() {
      const [lebar, setLebar] = useState(window.innerWidth);
      useEffect(() => {
        function handleResize() {
          setLebar(window.innerWidth);
        }
        window.addEventListener('resize', handleResize);
        return () => window.removeEventListener('resize', handleResize);
      }, []);
      return <p>Lebar layar: {lebar}px {lebar < 768 ? '(mobile)' : '(desktop)'}</p>;
    }
    
    function DaftarUser() {
      const [users, setUsers] = useState([]);
      const [loading, setLoading] = useState(true);
      const [error, setError] = useState(null);
    
      useEffect(() => {
        let ignore = false;
    
        async function ambilData() {
          try {
            const res = await fetch('https://jsonplaceholder.typicode.com/users');
            if (!res.ok) throw new Error(`HTTP ${res.status}`);
            const data = await res.json();
            if (!ignore) setUsers(data);
          } catch (err) {
            if (!ignore) setError(err.message);
          } finally {
            if (!ignore) setLoading(false);
          }
        }
    
        ambilData();
        return () => {
          ignore = true;
        };
      }, []);
    
      if (loading) return <p>Memuat pengguna...</p>;
      if (error) return <p style={{ color: 'crimson' }}>Gagal: {error}</p>;
      return (
        <ul>
          {users.map(u => <li key={u.id}>{u.name} - {u.email}</li>)}
        </ul>
      );
    }
    // → Memuat pengguna... lalu 10 nama dari API
    
    export default function App() {
      const [tampilStopwatch, setTampilStopwatch] = useState(true);
      const [pesan, setPesan] = useState(3);
      return (
        <>
          <JudulTab jumlahPesan={pesan} />
          <button onClick={() => setPesan(p => p + 1)}>Pesan masuk</button>
          <button onClick={() => setTampilStopwatch(t => !t)}>Toggle stopwatch</button>
          {tampilStopwatch && <Stopwatch />}
          <LebarLayar />
          <DaftarUser />
        </>
      );
    }
    

    Contoh effect yang tidak perlu dan perbaikannya:

    import { useState } from 'react';
    
    const KERANJANG = [
      { id: 1, nama: 'Kopi', harga: 85000, qty: 2 },
      { id: 2, nama: 'Teh', harga: 30000, qty: 1 },
    ];
    
    // ❌ Effect berlebihan: render dua kali, state ganda
    // const [total, setTotal] = useState(0);
    // useEffect(() => { setTotal(items.reduce(...)); }, [items]);
    
    // ✅ Hitung langsung saat render
    export function Ringkasan() {
      const [items] = useState(KERANJANG);
      const total = items.reduce((sum, i) => sum + i.harga * i.qty, 0);
    
      function handleCheckout() {
        // ✅ Aksi pengguna → langsung di event handler, bukan di effect
        console.log('Kirim pesanan senilai', total);
      }
    
      return (
        <>
          <p>Total: Rp {total.toLocaleString('id-ID')}</p> {/* → Total: Rp 200.000 */}
          <button onClick={handleCheckout}>Checkout</button>
        </>
      );
    }
    

    Kesalahan umum

    1. Membuat fungsi effect async. useEffect(async () => {...}) mengembalikan Promise, bukan cleanup. Buat fungsi async di dalamnya lalu panggil.
    2. Lupa cleanup untuk timer/listener. Interval berjalan dobel dan listener menumpuk. Selalu return fungsi pembersih.
    3. Dependency array tidak lengkap. Effect membaca state lama (stale). Cantumkan semua nilai reaktif yang dipakai; jangan bungkam peringatan linter.
    4. Memakai effect untuk data turunan. Menghitung total, filter, atau format di effect + setState menyebabkan render ganda. Hitung saat render.
    5. Memakai effect untuk merespons klik. Logika karena aksi pengguna tempatnya di event handler.

    Latihan

    1. Mudah, Buat komponen yang mengubah document.title menjadi "Halo, {nama}" setiap kali input nama berubah.
    2. Sedang, Buat komponen JamDigital yang menampilkan jam sekarang (toLocaleTimeString('id-ID')) dan diperbarui setiap detik, dengan cleanup yang benar. Tambahkan tombol untuk menyembunyikan komponen dan pastikan timer berhenti.
    3. Menantang, Fetch daftar post dari https://jsonplaceholder.typicode.com/posts?_limit=5 dan tampilkan judulnya, lengkap dengan state loading dan error. Lalu ubah URL menjadi salah (misalnya /postsxx) untuk menguji pesan error.
    Lihat solusi

    1.

    import { useEffect, useState } from 'react';
    
    export default function JudulDinamis() {
      const [nama, setNama] = useState('');
      useEffect(() => {
        document.title = nama ? `Halo, ${nama}` : 'Kodingup Camp';
      }, [nama]);
      return <input value={nama} onChange={e => setNama(e.target.value)} placeholder="Nama kamu" />;
    }
    

    2.

    import { useEffect, useState } from 'react';
    
    function JamDigital() {
      const [waktu, setWaktu] = useState(() => new Date());
      useEffect(() => {
        const id = setInterval(() => setWaktu(new Date()), 1000);
        return () => {
          clearInterval(id);
          console.log('Timer dihentikan'); // → muncul saat komponen disembunyikan
        };
      }, []);
      return <h2>{waktu.toLocaleTimeString('id-ID')}</h2>;
    }
    
    export default function App() {
      const [tampil, setTampil] = useState(true);
      return (
        <>
          <button onClick={() => setTampil(t => !t)}>{tampil ? 'Sembunyikan' : 'Tampilkan'} jam</button>
          {tampil && <JamDigital />}
        </>
      );
    }
    

    3.

    import { useEffect, useState } from 'react';
    
    export default function DaftarPost() {
      const [posts, setPosts] = useState([]);
      const [status, setStatus] = useState('loading'); // 'loading' | 'error' | 'sukses'
      const [pesanError, setPesanError] = useState('');
    
      useEffect(() => {
        let ignore = false;
        async function ambil() {
          try {
            const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5');
            if (!res.ok) throw new Error(`Server membalas ${res.status}`);
            const data = await res.json();
            if (!ignore) {
              setPosts(data);
              setStatus('sukses');
            }
          } catch (err) {
            if (!ignore) {
              setPesanError(err.message);
              setStatus('error');
            }
          }
        }
        ambil();
        return () => {
          ignore = true;
        };
      }, []);
    
      if (status === 'loading') return <p>Memuat...</p>;
      if (status === 'error') return <p>❌ {pesanError}</p>; // URL salah → "Server membalas 404"
      return (
        <ol>
          {posts.map(p => <li key={p.id}>{p.title}</li>)}
        </ol>
      );
    }
    

    Ringkasan

    Kuis pemahaman

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