Bab 3: Sinkronisasi dengan Effects

5 menit baca

Apa Itu Side Effect?

Bayangin kamu kerja di warung kopi. Tugas utama kamu: bikin kopi sesuai pesanan (render). Tapi kadang ada tugas tambahan yang bukan bagian dari bikin kopi itu sendiri:

  • Nyalain musik di speaker (koneksi ke sistem audio)
  • Update papan menu digital (sinkronisasi dengan server)
  • Kirim notifikasi ke pelanggan kalau kopinya udah siap (interaksi dengan sistem luar)

Tugas-tugas tambahan ini disebut side effects. Mereka bukan bagian dari proses "bikin output" (render), tapi tetap harus dilakuin.

Di React, Effect (dengan huruf E besar) adalah mekanisme buat menjalankan side effects yang perlu disinkronkan dengan sistem di luar React.

Kenapa Butuh useEffect?

React punya satu aturan penting: fungsi komponen harus pure. Artinya, kalau dikasih props dan state yang sama, hasilnya harus selalu JSX yang sama. Nggak boleh ada efek samping di dalamnya.

Tapi kenyataannya, aplikasi butuh:

  • Fetch data dari API
  • Koneksi ke WebSocket
  • Set timer
  • Manipulasi DOM langsung
  • Subscribe ke event browser (resize, scroll)

Semua ini nggak bisa dilakuin di body render. Makanya ada useEffect.

jsx
import { useEffect } from 'react';

function Komponen() {
  // ❌ JANGAN lakuin side effect di sini (body render)
  // fetch('https://api.com/data'); // Salah!
  
  // ✅ Lakuin di useEffect
  useEffect(() => {
    fetch('https://api.com/data')
      .then(res => res.json())
      .then(data => console.log(data));
  }, []);
  
  return <div>Halo</div>;
}

Anatomi useEffect

jsx
useEffect(() => {
  // 1. KODE EFFECT: jalan setelah render
  console.log('Effect jalan!');
  
  // 2. CLEANUP (opsional): jalan sebelum effect berikutnya atau saat unmount
  return () => {
    console.log('Cleanup jalan!');
  };
}, [dependency1, dependency2]); // 3. DEPENDENCY ARRAY: kontrol kapan effect jalan

Tiga bagian penting:

  1. Fungsi effect: kode yang mau dijalanin
  2. Cleanup function (return): bersih-bersih sebelum effect jalan lagi
  3. Dependency array: daftar nilai yang kalau berubah, effect jalan ulang

Dependency Array: Tiga Variasi

Variasi 1: Tanpa Array (Jalan Setiap Render)

jsx
useEffect(() => {
  console.log('Jalan setiap kali komponen render');
});
// Tanpa [] = jalan setiap render. Jarang dipakai, biasanya bug.

Variasi 2: Array Kosong [] (Jalan Sekali Saat Mount)

jsx
useEffect(() => {
  console.log('Jalan SEKALI saat komponen pertama kali muncul');
  
  return () => {
    console.log('Jalan SEKALI saat komponen dihapus dari layar');
  };
}, []);
// [] = cuma jalan saat mount, cleanup saat unmount

Variasi 3: Array dengan Dependencies (Jalan Saat Dependency Berubah)

jsx
useEffect(() => {
  console.log(`roomId berubah jadi: ${roomId}`);
  
  return () => {
    console.log(`Disconnect dari room: ${roomId}`);
  };
}, [roomId]);
// [roomId] = jalan saat mount DAN setiap kali roomId berubah

Analogi: Dependency Array itu Kayak Alarm

Bayangin dependency array itu kayak setting alarm:

  • Tanpa array = alarm bunyi setiap detik. Berisik, biasanya nggak mau begini.
  • [] = alarm bunyi sekali pas kamu bangun pagi, terus mati. Cocok buat setup awal.
  • [roomId] = alarm bunyi setiap kali kamu pindah ruangan. Cocok buat sinkronisasi.

Contoh 1: Koneksi ke Chat Room

Ini contoh klasik. Kamu punya aplikasi chat, dan user bisa pindah-pindah room:

jsx
import { useState, useEffect } from 'react';

// Simulasi koneksi chat
function buatKoneksi(roomId) {
  return {
    connect() {
      console.log(`✅ Terhubung ke room "${roomId}"`);
    },
    disconnect() {
      console.log(`❌ Terputus dari room "${roomId}"`);
    }
  };
}

function ChatRoom({ roomId }) {
  useEffect(() => {
    // Setup: koneksi ke room
    const koneksi = buatKoneksi(roomId);
    koneksi.connect();
    
    // Cleanup: putus koneksi saat pindah room atau unmount
    return () => {
      koneksi.disconnect();
    };
  }, [roomId]); // Jalan ulang setiap kali roomId berubah
  
  return <h2>Selamat datang di room: {roomId}</h2>;
}

function App() {
  const [room, setRoom] = useState('umum');
  
  return (
    <div>
      <select value={room} onChange={(e) => setRoom(e.target.value)}>
        <option value="umum">Room Umum</option>
        <option value="teknologi">Room Teknologi</option>
        <option value="random">Room Random</option>
      </select>
      
      <ChatRoom roomId={room} />
    </div>
  );
}

Apa yang terjadi saat user ganti room dari "umum" ke "teknologi":

  1. Cleanup jalan: ❌ Terputus dari room "umum"
  2. Effect baru jalan: ✅ Terhubung ke room "teknologi"

Tanpa cleanup, kamu bakal punya koneksi zombie ke room lama yang nggak pernah diputus!

Contoh 2: Timer dengan Cleanup

jsx
import { useState, useEffect } from 'react';

function Jam() {
  const [waktu, setWaktu] = useState(new Date());
  
  useEffect(() => {
    // Setup: bikin interval yang update waktu setiap detik
    const intervalId = setInterval(() => {
      setWaktu(new Date());
    }, 1000);
    
    // Cleanup: hapus interval saat komponen unmount
    return () => {
      clearInterval(intervalId);
      console.log('Interval dibersihkan!');
    };
  }, []); // [] = setup sekali, cleanup saat unmount
  
  return (
    <div>
      <h1>{waktu.toLocaleTimeString('id-ID')}</h1>
    </div>
  );
}

Kenapa cleanup penting di sini? Kalau komponen Jam dihapus dari layar (unmount) tapi interval nggak di-clear, interval itu tetap jalan di background. Itu memory leak! Kayak ninggalin keran air nyala pas pergi liburan.

Contoh 3: Event Listener

jsx
import { useState, useEffect } from 'react';

function PelacakMouse() {
  const [posisi, setPosisi] = useState({ x: 0, y: 0 });
  
  useEffect(() => {
    function handleGerakMouse(event) {
      setPosisi({ x: event.clientX, y: event.clientY });
    }
    
    // Setup: pasang event listener
    window.addEventListener('mousemove', handleGerakMouse);
    
    // Cleanup: lepas event listener
    return () => {
      window.removeEventListener('mousemove', handleGerakMouse);
    };
  }, []); // Pasang sekali, lepas saat unmount
  
  return (
    <div>
      <p>Mouse di posisi: ({posisi.x}, {posisi.y})</p>
      <div
        style={{
          position: 'fixed',
          left: posisi.x - 10,
          top: posisi.y - 10,
          width: 20,
          height: 20,
          borderRadius: '50%',
          background: 'red',
          pointerEvents: 'none'
        }}
      />
    </div>
  );
}

Contoh 4: Fetch Data dari API

jsx
import { useState, useEffect } from 'react';

function ProfilUser({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    let dibatalkan = false; // Flag buat handle race condition
    
    setLoading(true);
    setError(null);
    
    fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error('Gagal fetch');
        return res.json();
      })
      .then(data => {
        // Cek apakah effect ini masih relevan
        if (!dibatalkan) {
          setUser(data);
          setLoading(false);
        }
      })
      .catch(err => {
        if (!dibatalkan) {
          setError(err.message);
          setLoading(false);
        }
      });
    
    // Cleanup: tandai effect ini sudah nggak relevan
    return () => {
      dibatalkan = true;
    };
  }, [userId]); // Fetch ulang setiap userId berubah
  
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
      <p>Kota: {user.address.city}</p>
    </div>
  );
}

Kenapa ada flag dibatalkan?

Bayangin user klik cepat: User 1 → User 2 → User 3. Tiga fetch dikirim. Tapi response bisa datang nggak urut (User 2 duluan, baru User 1, baru User 3). Tanpa flag ini, data User 1 bisa nimpa data User 3 yang harusnya ditampilin.

Dengan flag dibatalkan, saat userId berubah:

  1. Cleanup jalan → dibatalkan = true buat fetch lama
  2. Effect baru jalan → fetch baru dengan dibatalkan = false
  3. Kalau fetch lama selesai, dia cek dibatalkan dan nggak update state

Contoh 5: WebSocket

jsx
import { useState, useEffect } from 'react';

function LiveChat({ roomId }) {
  const [pesan, setPesan] = useState([]);
  const [input, setInput] = useState('');
  
  useEffect(() => {
    // Setup: buka koneksi WebSocket
    const ws = new WebSocket(`wss://chat.example.com/room/${roomId}`);
    
    ws.onopen = () => {
      console.log('WebSocket terhubung');
    };
    
    ws.onmessage = (event) => {
      const pesanBaru = JSON.parse(event.data);
      setPesan(prev => [...prev, pesanBaru]);
    };
    
    ws.onerror = (error) => {
      console.error('WebSocket error:', error);
    };
    
    // Cleanup: tutup koneksi
    return () => {
      ws.close();
      console.log('WebSocket ditutup');
    };
  }, [roomId]); // Reconnect kalau pindah room
  
  return (
    <div>
      <h3>Room: {roomId}</h3>
      <div style={{ height: '200px', overflow: 'auto' }}>
        {pesan.map((p, i) => (
          <p key={i}><strong>{p.user}:</strong> {p.teks}</p>
        ))}
      </div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="Ketik pesan..."
      />
    </div>
  );
}

Effect vs Event Handler: Apa Bedanya?

Ini pertanyaan yang sering bikin bingung. Kapan pakai Effect, kapan pakai event handler?

AspekEvent HandlerEffect
Kapan jalanSaat user melakukan aksi spesifikSaat komponen perlu sinkronisasi
TriggerKlik, ketik, submit, hoverRender (mount/update)
ContohKirim form, tambah itemKoneksi API, subscribe event
AnalogiPelayan nerima pesananAC otomatis nyala saat suhu naik
jsx
function ContohPerbedaan() {
  const [pesan, setPesan] = useState('');
  const [daftarPesan, setDaftarPesan] = useState([]);
  
  // EVENT HANDLER: jalan saat user klik tombol kirim
  // User yang trigger, bukan React
  function handleKirim() {
    setDaftarPesan([...daftarPesan, pesan]);
    setPesan('');
    // Kirim ke server
    fetch('/api/pesan', {
      method: 'POST',
      body: JSON.stringify({ teks: pesan })
    });
  }
  
  // EFFECT: jalan otomatis saat daftarPesan berubah
  // React yang trigger, bukan user langsung
  useEffect(() => {
    // Update title halaman setiap ada pesan baru
    document.title = `Chat (${daftarPesan.length} pesan)`;
  }, [daftarPesan]);
  
  return (
    <div>
      <input value={pesan} onChange={(e) => setPesan(e.target.value)} />
      <button onClick={handleKirim}>Kirim</button>
    </div>
  );
}

Aturan simpel:

  • "Saat user klik/ketik/submit..." → Event handler
  • "Setiap kali X berubah, sinkronkan Y..." → Effect

Strict Mode dan Double Effect

Kalau kamu pakai React Strict Mode (default di Create React App dan Next.js), kamu bakal lihat Effect jalan DUA KALI saat development:

jsx
useEffect(() => {
  console.log('Connect'); // Muncul 2x di development!
  return () => {
    console.log('Disconnect'); // Cleanup juga jalan
  };
}, []);

// Output di console (development):
// Connect
// Disconnect
// Connect

Jangan panik! Ini sengaja. React sengaja mount → unmount → mount lagi buat ngetes apakah cleanup kamu bener. Kalau setelah cleanup + re-run hasilnya sama kayak cuma run sekali, berarti Effect kamu udah bener.

Ini CUMA di development. Di production, Effect cuma jalan sekali.

Pola Umum: Loading State

jsx
import { useState, useEffect } from 'react';

function DaftarProduk({ kategori }) {
  const [produk, setProduk] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    let aktif = true;
    
    async function fetchProduk() {
      setLoading(true);
      setError(null);
      
      try {
        const res = await fetch(`/api/produk?kategori=${kategori}`);
        if (!res.ok) throw new Error('Gagal memuat produk');
        const data = await res.json();
        
        if (aktif) {
          setProduk(data);
        }
      } catch (err) {
        if (aktif) {
          setError(err.message);
        }
      } finally {
        if (aktif) {
          setLoading(false);
        }
      }
    }
    
    fetchProduk();
    
    return () => {
      aktif = false;
    };
  }, [kategori]);
  
  if (loading) return <p>Memuat produk...</p>;
  if (error) return <p style={{ color: 'red' }}>Error: {error}</p>;
  
  return (
    <ul>
      {produk.map(p => (
        <li key={p.id}>{p.nama} - Rp{p.harga.toLocaleString()}</li>
      ))}
    </ul>
  );
}

Pola Umum: Document Title

jsx
import { useEffect } from 'react';

function Halaman({ judul, children }) {
  useEffect(() => {
    // Simpan title lama
    const titleLama = document.title;
    
    // Set title baru
    document.title = judul;
    
    // Cleanup: kembalikan title lama saat unmount
    return () => {
      document.title = titleLama;
    };
  }, [judul]);
  
  return <div>{children}</div>;
}

// Pemakaian
function App() {
  return (
    <Halaman judul="Dashboard - Toko Online">
      <h1>Dashboard</h1>
    </Halaman>
  );
}

⚠️ Jebakan

Jebakan 1: Infinite Loop (Dependency yang Selalu Berubah)

jsx
// ❌ INFINITE LOOP!
function InfiniteLoop() {
  const [data, setData] = useState([]);
  
  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(d => setData(d)); // setData trigger render
  }); // Tanpa dependency array = jalan setiap render = loop!
  
  return <div>{data.length} items</div>;
}

// ✅ Tambahkan dependency array
function Benar() {
  const [data, setData] = useState([]);
  
  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(d => setData(d));
  }, []); // Jalan sekali aja
  
  return <div>{data.length} items</div>;
}

Jebakan 2: Object/Array sebagai Dependency

jsx
// ❌ Effect jalan setiap render karena object baru setiap render!
function Salah({ userId }) {
  const options = { userId: userId, limit: 10 }; // Object baru setiap render
  
  useEffect(() => {
    fetch('/api/data', { body: JSON.stringify(options) });
  }, [options]); // options selalu "beda" (referensi baru) → infinite loop!
}

// ✅ Pakai nilai primitif sebagai dependency
function Benar({ userId }) {
  useEffect(() => {
    const options = { userId: userId, limit: 10 }; // Bikin di dalam Effect
    fetch('/api/data', { body: JSON.stringify(options) });
  }, [userId]); // userId itu string/number, bisa dibandingkan
}

Jebakan 3: Lupa Cleanup

jsx
// ❌ Memory leak! Event listener nggak pernah dilepas
function Salah() {
  useEffect(() => {
    window.addEventListener('resize', handleResize);
    // Lupa return cleanup!
  }, []);
}

// ✅ Selalu cleanup
function Benar() {
  useEffect(() => {
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);
}

Jebakan 4: Async di useEffect

jsx
// ❌ useEffect nggak boleh return Promise!
useEffect(async () => {
  const data = await fetch('/api/data');
  // ...
}, []);
// Error: Effect callbacks are synchronous to prevent race conditions

// ✅ Bikin fungsi async di dalam Effect
useEffect(() => {
  async function fetchData() {
    const res = await fetch('/api/data');
    const data = await res.json();
    setData(data);
  }
  
  fetchData();
}, []);

Jebakan 5: Dependency yang Nggak Lengkap

jsx
// ❌ React bakal warning: missing dependency 'count'
function Salah() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // Pakai count tapi nggak di dependency
    }, 1000);
    return () => clearInterval(id);
  }, []); // count nggak ada di sini!
  // Hasilnya: count selalu 0 + 1 = 1
  
  return <p>{count}</p>;
}

// ✅ Pakai updater function
function Benar() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1); // Updater function, nggak perlu count di dependency
    }, 1000);
    return () => clearInterval(id);
  }, []); // Aman!
  
  return <p>{count}</p>;
}

Ringkasan

  1. useEffect buat sinkronisasi komponen dengan sistem luar (API, DOM, timer, dll)
  2. Dependency array kontrol kapan Effect jalan ulang
  3. Cleanup function bersihkan resource saat Effect jalan ulang atau komponen unmount
  4. Event handler buat respons ke aksi user, Effect buat sinkronisasi otomatis
  5. Selalu handle race condition di fetch (flag dibatalkan)
  6. Jangan lupa cleanup buat: interval, event listener, koneksi, subscription

🏋️ Challenge

Challenge 1: Komponen Online/Offline Indicator

Bikin komponen yang nunjukin apakah user sedang online atau offline, menggunakan event online dan offline dari window.

Hint: window.addEventListener('online', ...) dan window.addEventListener('offline', ...)

Lihat Solusi
jsx
import { useState, useEffect } from 'react';

function StatusKoneksi() {
  const [online, setOnline] = useState(navigator.onLine);
  
  useEffect(() => {
    function handleOnline() {
      setOnline(true);
    }
    
    function handleOffline() {
      setOnline(false);
    }
    
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    
    // Cleanup: lepas kedua listener
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);
  
  return (
    <div style={{
      padding: '10px',
      background: online ? '#c8e6c9' : '#ffcdd2',
      borderRadius: '8px'
    }}>
      {online ? '🟢 Online' : '🔴 Offline'}
      <p>
        {online
          ? 'Koneksi internet aktif'
          : 'Tidak ada koneksi internet. Cek WiFi kamu.'}
      </p>
    </div>
  );
}

Challenge 2: Auto-Save Form

Bikin form yang otomatis menyimpan ke localStorage setiap 3 detik (kalau ada perubahan). Saat halaman dibuka ulang, isi form ter-restore.

Hint: Pakai useEffect dengan interval + cek apakah data berubah dari terakhir disimpan.

Lihat Solusi
jsx
import { useState, useEffect, useRef } from 'react';

function FormAutoSave() {
  // Restore dari localStorage saat mount
  const [formData, setFormData] = useState(() => {
    const tersimpan = localStorage.getItem('draft-form');
    return tersimpan
      ? JSON.parse(tersimpan)
      : { judul: '', isi: '' };
  });
  const [statusSimpan, setStatusSimpan] = useState('');
  const dataSebelumnya = useRef(formData);
  
  // Auto-save setiap 3 detik kalau ada perubahan
  useEffect(() => {
    const intervalId = setInterval(() => {
      // Cek apakah data berubah
      if (JSON.stringify(formData) !== JSON.stringify(dataSebelumnya.current)) {
        localStorage.setItem('draft-form', JSON.stringify(formData));
        dataSebelumnya.current = formData;
        setStatusSimpan('✅ Tersimpan otomatis');
        
        // Hilangkan pesan setelah 2 detik
        setTimeout(() => setStatusSimpan(''), 2000);
      }
    }, 3000);
    
    return () => clearInterval(intervalId);
  }, [formData]);
  
  function handleReset() {
    setFormData({ judul: '', isi: '' });
    localStorage.removeItem('draft-form');
    setStatusSimpan('🗑️ Draft dihapus');
  }
  
  return (
    <div>
      <h2>Form dengan Auto-Save</h2>
      
      <div>
        <label>Judul:</label>
        <input
          value={formData.judul}
          onChange={(e) => setFormData({ ...formData, judul: e.target.value })}
        />
      </div>
      
      <div>
        <label>Isi:</label>
        <textarea
          value={formData.isi}
          onChange={(e) => setFormData({ ...formData, isi: e.target.value })}
          rows={5}
        />
      </div>
      
      <button onClick={handleReset}>Reset</button>
      
      {statusSimpan && (
        <p style={{ color: 'green', fontSize: '14px' }}>{statusSimpan}</p>
      )}
    </div>
  );
}

Challenge 3: Countdown Timer dengan Pause/Resume

Bikin countdown timer yang bisa di-pause dan di-resume. User input berapa detik, terus timer mulai hitung mundur.

Hint: Pakai useEffect yang depend pada status sedangJalan. Cleanup interval setiap kali status berubah.

Lihat Solusi
jsx
import { useState, useEffect } from 'react';

function CountdownTimer() {
  const [inputDetik, setInputDetik] = useState(60);
  const [sisaWaktu, setSisaWaktu] = useState(0);
  const [sedangJalan, setSedangJalan] = useState(false);
  const [selesai, setSelesai] = useState(false);
  
  // Effect yang jalan/berhenti berdasarkan sedangJalan
  useEffect(() => {
    if (!sedangJalan) return; // Kalau nggak jalan, nggak bikin interval
    
    const intervalId = setInterval(() => {
      setSisaWaktu(prev => {
        if (prev <= 1) {
          setSedangJalan(false);
          setSelesai(true);
          return 0;
        }
        return prev - 1;
      });
    }, 1000);
    
    // Cleanup: hapus interval saat pause atau unmount
    return () => clearInterval(intervalId);
  }, [sedangJalan]); // Re-run saat sedangJalan berubah
  
  function handleMulai() {
    setSisaWaktu(inputDetik);
    setSedangJalan(true);
    setSelesai(false);
  }
  
  function handlePause() {
    setSedangJalan(false);
  }
  
  function handleResume() {
    setSedangJalan(true);
  }
  
  function handleReset() {
    setSedangJalan(false);
    setSisaWaktu(0);
    setSelesai(false);
  }
  
  // Format jadi MM:SS
  const menit = Math.floor(sisaWaktu / 60);
  const detik = sisaWaktu % 60;
  
  return (
    <div>
      <h2>Countdown Timer</h2>
      
      {sisaWaktu === 0 && !selesai && (
        <div>
          <input
            type="number"
            value={inputDetik}
            onChange={(e) => setInputDetik(Number(e.target.value))}
            min="1"
          />
          <span> detik</span>
          <button onClick={handleMulai}>Mulai</button>
        </div>
      )}
      
      {(sisaWaktu > 0 || selesai) && (
        <div>
          <h1 style={{ fontSize: '48px', fontFamily: 'monospace' }}>
            {String(menit).padStart(2, '0')}:{String(detik).padStart(2, '0')}
          </h1>
          
          {selesai && <p style={{ color: 'red', fontSize: '24px' }}>⏰ Waktu habis!</p>}
          
          {!selesai && (
            <>
              {sedangJalan ? (
                <button onClick={handlePause}>⏸ Pause</button>
              ) : (
                <button onClick={handleResume}>▶️ Resume</button>
              )}
            </>
          )}
          <button onClick={handleReset}>🔄 Reset</button>
        </div>
      )}
    </div>
  );
}

Penjelasan kunci:

  • Effect depend pada sedangJalan. Saat true, bikin interval. Saat false, cleanup hapus interval.
  • Pakai updater function setSisaWaktu(prev => ...) supaya nggak perlu sisaWaktu di dependency array.
  • Cleanup otomatis jalan saat sedangJalan berubah dari true ke false (pause).

Sudah paham materi ini?

Tandai sebagai selesai untuk melacak progress-mu.