Bab 5: Lifecycle dari Reactive Effects

5 menit baca

Cara Berpikir yang Benar tentang Effect

Kebanyakan pemula mikir Effect itu kayak lifecycle method di class component:

  • componentDidMountuseEffect(..., [])
  • componentDidUpdateuseEffect(..., [dep])
  • componentWillUnmount → cleanup function

Ini cara berpikir yang SALAH. Lupakan lifecycle komponen. Sebaliknya, pikirin Effect sebagai proses sinkronisasi yang independen.

Analogi: bayangin AC otomatis di ruangan. AC nggak peduli "kapan ruangan dibuat" atau "kapan ruangan dihancurkan". AC cuma peduli satu hal: suhu ruangan. Kalau suhu naik, AC nyala. Kalau suhu turun, AC mati. Itu aja.

Effect juga begitu. Dia nggak peduli lifecycle komponen. Dia cuma peduli: apakah dependency-nya berubah?

Lifecycle Effect: Start dan Stop

Setiap Effect punya dua fase:

  1. Start (setup): mulai sinkronisasi
  2. Stop (cleanup): berhenti sinkronisasi
jsx
useEffect(() => {
  // === START: mulai sinkronisasi ===
  const koneksi = buatKoneksi(roomId);
  koneksi.connect();
  
  // === STOP: berhenti sinkronisasi ===
  return () => {
    koneksi.disconnect();
  };
}, [roomId]);

Timeline:

  1. Komponen mount dengan roomId = "umum" → Effect START (connect ke "umum")
  2. roomId berubah jadi "teknologi" → Effect STOP (disconnect dari "umum") → Effect START (connect ke "teknologi")
  3. roomId berubah jadi "random" → Effect STOP (disconnect dari "teknologi") → Effect START (connect ke "random")
  4. Komponen unmount → Effect STOP (disconnect dari "random")

Perhatiin: setiap kali dependency berubah, Effect stop dulu, baru start lagi dengan nilai baru. Kayak ganti channel TV: matiin channel lama, nyalain channel baru.

Setiap Render Punya Effect-nya Sendiri

Ini konsep penting. Setiap kali komponen render, dia "bikin" Effect baru. Tapi React cuma jalanin Effect kalau dependency-nya berubah.

jsx
function ChatRoom({ roomId }) {
  useEffect(() => {
    const koneksi = buatKoneksi(roomId);
    koneksi.connect();
    return () => koneksi.disconnect();
  }, [roomId]);
  
  return <h1>Room: {roomId}</h1>;
}

Render 1 (roomId = "umum"):

jsx
// Effect yang "dibuat" di render 1:
() => {
  const koneksi = buatKoneksi("umum");  // roomId = "umum" di render ini
  koneksi.connect();
  return () => koneksi.disconnect();
}
// React jalanin karena ini render pertama

Render 2 (roomId = "teknologi"):

jsx
// Effect yang "dibuat" di render 2:
() => {
  const koneksi = buatKoneksi("teknologi");  // roomId = "teknologi" di render ini
  koneksi.connect();
  return () => koneksi.disconnect();
}
// React jalanin karena roomId berubah dari "umum" ke "teknologi"
// Tapi SEBELUM jalanin ini, React jalanin cleanup dari render 1 dulu!

Jadi urutan eksekusi:

  1. Cleanup render 1: disconnect dari "umum"
  2. Setup render 2: connect ke "teknologi"

Cleanup Jalan SEBELUM Effect Berikutnya

Ini yang sering bikin bingung. Cleanup bukan cuma jalan saat unmount. Cleanup jalan setiap kali sebelum Effect jalan ulang.

jsx
function DemoCleanup({ pesan }) {
  useEffect(() => {
    console.log(`✅ Setup: "${pesan}"`);
    
    return () => {
      console.log(`🧹 Cleanup: "${pesan}"`);
    };
  }, [pesan]);
  
  return <p>{pesan}</p>;
}

// Kalau pesan berubah dari "Halo" → "Hai" → "Hey":
// Console output:
// ✅ Setup: "Halo"          (mount)
// 🧹 Cleanup: "Halo"       (sebelum effect baru)
// ✅ Setup: "Hai"           (effect baru)
// 🧹 Cleanup: "Hai"        (sebelum effect baru)
// ✅ Setup: "Hey"           (effect baru)
// 🧹 Cleanup: "Hey"        (unmount)

Analogi: Bayangin kamu kerja di warung dan harus ganti papan promo setiap hari. Sebelum pasang papan baru, kamu copot papan lama dulu. Cleanup = copot papan lama. Setup = pasang papan baru.

Reactive Values: Apa yang Harus Jadi Dependency?

Reactive values = nilai yang bisa berubah antar render. Ini termasuk:

  • Props
  • State
  • Variabel yang dihitung dari props/state
jsx
function ChatRoom({ roomId, serverUrl }) {  // props = reactive
  const [pesan, setPesan] = useState('');    // state = reactive
  const url = serverUrl + '/chat';           // dihitung dari props = reactive
  
  useEffect(() => {
    const koneksi = buatKoneksi(url, roomId);
    koneksi.connect();
    return () => koneksi.disconnect();
  }, [url, roomId]); // Semua reactive values yang dipakai di Effect HARUS di sini
  
  // ...
}

Yang BUKAN reactive (nggak perlu di dependency):

  • Nilai yang didefinisikan di luar komponen (konstanta)
  • Ref (karena .current bisa berubah tanpa trigger render)
  • Setter function dari useState (stabil antar render)
jsx
const API_URL = 'https://api.example.com'; // Konstanta, nggak reactive

function Komponen() {
  const [data, setData] = useState(null);
  const timerRef = useRef(null);
  
  useEffect(() => {
    fetch(API_URL)  // API_URL nggak perlu di dependency (konstanta)
      .then(res => res.json())
      .then(d => setData(d)); // setData nggak perlu di dependency (stabil)
    
    timerRef.current = 123; // timerRef nggak perlu di dependency
  }, []); // Benar! Semua yang dipakai di sini memang nggak reactive
}

Kenapa React Memaksa Dependency Lengkap?

React punya linter rule (react-hooks/exhaustive-deps) yang memaksa kamu masukin semua reactive values ke dependency array. Kenapa?

Karena kalau nggak, Effect kamu bakal "basi" (stale).

jsx
function Timer({ interval }) {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // ❌ count "basi"! Selalu 0 dari render pertama
    }, interval);
    return () => clearInterval(id);
  }, []); // ❌ Linter warning: missing 'count' and 'interval'
  
  // Masalah: count selalu 0 + 1 = 1, nggak pernah naik!
  // Dan kalau interval berubah, timer nggak update!
}

Solusi yang benar:

jsx
function Timer({ interval }) {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1); // ✅ Updater function, nggak perlu count di dependency
    }, interval);
    return () => clearInterval(id);
  }, [interval]); // ✅ interval di dependency, timer restart kalau interval berubah
  
  return <p>Count: {count}</p>;
}

Berpikir dalam Sinkronisasi, Bukan Lifecycle

Jangan pikir: "Effect ini jalan saat mount dan update" Pikir: "Effect ini menjaga koneksi tetap sinkron dengan roomId"

jsx
// ❌ Cara pikir lifecycle (salah):
// "Saat mount, connect. Saat roomId update, reconnect. Saat unmount, disconnect."

// ✅ Cara pikir sinkronisasi (benar):
// "Effect ini menjaga koneksi chat selalu terhubung ke room yang benar."
// Kalau roomId berubah, koneksi harus di-reset ke room baru.
// Kalau komponen hilang, koneksi harus ditutup.

Kenapa cara pikir ini lebih baik? Karena kalau kamu pikir "sinkronisasi", kamu otomatis nulis cleanup yang benar. Kamu nggak lupa disconnect saat roomId berubah.

Contoh: Effect dengan Multiple Dependencies

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

function NotifikasiChat({ roomId, userId, notifAktif }) {
  useEffect(() => {
    if (!notifAktif) return; // Early return kalau notif dimatiin
    
    console.log(`🔔 Subscribe notifikasi untuk user ${userId} di room ${roomId}`);
    
    // Simulasi subscribe ke notification service
    const subscription = {
      room: roomId,
      user: userId,
      aktif: true
    };
    
    return () => {
      console.log(`🔕 Unsubscribe notifikasi untuk user ${userId} di room ${roomId}`);
      subscription.aktif = false;
    };
  }, [roomId, userId, notifAktif]);
  // Effect re-run kalau SALAH SATU dari ketiga dependency berubah
  
  return <p>Notifikasi: {notifAktif ? 'Aktif' : 'Mati'}</p>;
}

Kapan Effect re-run?

  • roomId berubah → unsubscribe lama, subscribe baru (room beda)
  • userId berubah → unsubscribe lama, subscribe baru (user beda)
  • notifAktif berubah → unsubscribe lama, subscribe baru (atau skip kalau false)

Setiap perubahan dependency = stop + start ulang. Ini memastikan sinkronisasi selalu benar.

Contoh: Interval yang Berubah

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

function PollingData({ url, intervalMs }) {
  const [data, setData] = useState(null);
  const [lastUpdate, setLastUpdate] = useState(null);
  
  useEffect(() => {
    let aktif = true;
    
    // Fetch langsung saat Effect start
    async function fetchData() {
      try {
        const res = await fetch(url);
        const json = await res.json();
        if (aktif) {
          setData(json);
          setLastUpdate(new Date().toLocaleTimeString());
        }
      } catch (err) {
        console.error('Fetch gagal:', err);
      }
    }
    
    fetchData(); // Fetch pertama
    
    // Setup polling
    const intervalId = setInterval(fetchData, intervalMs);
    
    return () => {
      aktif = false;
      clearInterval(intervalId);
    };
  }, [url, intervalMs]);
  // Kalau url berubah: stop polling lama, mulai polling ke url baru
  // Kalau intervalMs berubah: stop interval lama, mulai dengan interval baru
  
  return (
    <div>
      <p>Data: {JSON.stringify(data)}</p>
      <p>Update terakhir: {lastUpdate}</p>
    </div>
  );
}

// Pemakaian
function App() {
  const [interval, setInterval] = useState(5000);
  
  return (
    <div>
      <select value={interval} onChange={e => setInterval(Number(e.target.value))}>
        <option value={1000}>Setiap 1 detik</option>
        <option value={5000}>Setiap 5 detik</option>
        <option value={30000}>Setiap 30 detik</option>
      </select>
      
      <PollingData url="/api/status" intervalMs={interval} />
    </div>
  );
}

Contoh: Animation Frame

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

function AnimasiBola({ kecepatanX, kecepatanY }) {
  const [posisi, setPosisi] = useState({ x: 50, y: 50 });
  const frameRef = useRef(null);
  
  useEffect(() => {
    let x = posisi.x;
    let y = posisi.y;
    
    function animate() {
      x += kecepatanX;
      y += kecepatanY;
      
      // Pantul di tepi
      if (x <= 0 || x >= 300) x = Math.max(0, Math.min(300, x));
      if (y <= 0 || y >= 300) y = Math.max(0, Math.min(300, y));
      
      setPosisi({ x, y });
      frameRef.current = requestAnimationFrame(animate);
    }
    
    frameRef.current = requestAnimationFrame(animate);
    
    return () => {
      cancelAnimationFrame(frameRef.current);
    };
  }, [kecepatanX, kecepatanY]);
  // Kalau kecepatan berubah, restart animasi dengan kecepatan baru
  
  return (
    <div style={{ width: 300, height: 300, border: '1px solid black', position: 'relative' }}>
      <div
        style={{
          position: 'absolute',
          left: posisi.x,
          top: posisi.y,
          width: 20,
          height: 20,
          borderRadius: '50%',
          background: 'red'
        }}
      />
    </div>
  );
}

Effect yang Nggak Perlu Cleanup

Nggak semua Effect butuh cleanup. Kalau Effect cuma "fire and forget" (jalanin sekali, nggak ada yang perlu dibersihkan):

jsx
// Nggak perlu cleanup
useEffect(() => {
  // Analytics: cuma kirim data, nggak ada subscription
  analytics.track('halaman_dilihat', { halaman: namaHalaman });
}, [namaHalaman]);

// Nggak perlu cleanup
useEffect(() => {
  // Set document title
  document.title = `${judul} | Toko Online`;
}, [judul]);

Kapan butuh cleanup:

  • Subscribe/unsubscribe (WebSocket, event listener)
  • Connect/disconnect (database, API)
  • Start/stop (timer, interval, animation)
  • Add/remove (DOM manipulation)

Kapan nggak butuh cleanup:

  • Logging/analytics
  • Set document.title
  • One-time DOM measurement
  • Fetch data (tapi tetap butuh flag aktif buat race condition)

Visualisasi Timeline Effect

Komponen: ChatRoom({ roomId: "umum" }) Timeline: ───────────────────────────────────────────────────────── Render 1 (roomId="umum") │ ├── React render JSX ├── React update DOM └── Effect START: connect("umum") ✅ │ Render 2 (roomId="teknologi") ← user ganti room │ ├── React render JSX ├── React update DOM ├── Effect CLEANUP: disconnect("umum") 🧹 └── Effect START: connect("teknologi") ✅ │ Render 3 (pesan berubah, roomId tetap "teknologi") │ ├── React render JSX ├── React update DOM └── (Effect NGGAK jalan karena roomId nggak berubah) ⏭️ │ Unmount │ └── Effect CLEANUP: disconnect("teknologi") 🧹 ─────────────────────────────────────────────────────────

Perhatiin Render 3: meskipun komponen render ulang (karena pesan berubah), Effect nggak jalan karena roomId (dependency-nya) nggak berubah. Ini efisiensi dari dependency array!

⚠️ Jebakan

Jebakan 1: Mikir Effect = Lifecycle

jsx
// ❌ Cara pikir salah: "jalanin ini saat mount"
useEffect(() => {
  // "Ini componentDidMount saya"
  fetchData();
  setupListener();
  initializeLibrary();
}, []);
// Masalah: semua dicampur jadi satu. Susah di-maintain.

// ✅ Cara pikir benar: pisahkan berdasarkan sinkronisasi
useEffect(() => {
  // Effect 1: sinkronisasi data
  fetchData();
}, []);

useEffect(() => {
  // Effect 2: sinkronisasi event listener
  window.addEventListener('resize', handler);
  return () => window.removeEventListener('resize', handler);
}, []);

useEffect(() => {
  // Effect 3: sinkronisasi library
  const instance = initLibrary(config);
  return () => instance.destroy();
}, [config]);

Setiap Effect harus punya satu tanggung jawab sinkronisasi. Jangan campur-campur!

Jebakan 2: Dependency yang Berubah Setiap Render

jsx
// ❌ Object baru setiap render = Effect jalan terus!
function Salah({ userId }) {
  const options = { userId, limit: 10 }; // Object baru setiap render!
  
  useEffect(() => {
    fetchData(options);
  }, [options]); // options selalu "beda" → infinite re-run!
}

// ✅ Pindahkan object ke dalam Effect
function Benar({ userId }) {
  useEffect(() => {
    const options = { userId, limit: 10 }; // Bikin di dalam Effect
    fetchData(options);
  }, [userId]); // Depend pada primitive value
}

Jebakan 3: Lupa Bahwa Cleanup Punya Nilai dari Render Sebelumnya

jsx
function Demo({ roomId }) {
  useEffect(() => {
    console.log(`Connect ke ${roomId}`);
    
    return () => {
      // roomId di sini = roomId dari RENDER INI, bukan render berikutnya!
      console.log(`Disconnect dari ${roomId}`);
    };
  }, [roomId]);
}

// Kalau roomId berubah dari "A" ke "B":
// Output: "Disconnect dari A" (bukan "Disconnect dari B"!)
// Lalu: "Connect ke B"

Ini karena closure. Cleanup function "ingat" nilai dari render di mana dia dibuat.

Jebakan 4: Effect Tanpa Dependency Array vs Array Kosong

jsx
// TANPA array: jalan SETIAP render (biasanya bug)
useEffect(() => {
  console.log('Jalan setiap render');
});

// ARRAY KOSONG: jalan SEKALI saat mount
useEffect(() => {
  console.log('Jalan sekali');
}, []);

// Ini DUA HAL YANG SANGAT BERBEDA!
// Jangan lupa [] kalau memang mau jalan sekali!

Ringkasan

  1. Jangan pikir lifecycle, pikir sinkronisasi
  2. Effect punya dua fase: start (setup) dan stop (cleanup)
  3. Cleanup jalan sebelum Effect berikutnya, bukan cuma saat unmount
  4. Setiap render "bikin" Effect baru, tapi React cuma jalanin kalau dependency berubah
  5. Reactive values (props, state, turunannya) harus masuk dependency array
  6. Satu Effect = satu tanggung jawab sinkronisasi. Jangan campur!
  7. Kalau dependency nggak berubah, Effect nggak jalan (efisien)

🏋️ Challenge

Challenge 1: Visualisasikan Urutan Effect

Tanpa menjalankan kode, prediksi output console dari komponen berikut saat count berubah dari 0 → 1 → 2:

jsx
function Demo() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    console.log(`Setup: ${count}`);
    return () => console.log(`Cleanup: ${count}`);
  }, [count]);
  
  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}

Hint: Ingat urutan: cleanup lama → setup baru.

Lihat Solusi
// Mount (count = 0): Setup: 0 // Klik pertama (count: 0 → 1): Cleanup: 0 ← cleanup dari Effect sebelumnya (count=0) Setup: 1 ← setup Effect baru (count=1) // Klik kedua (count: 1 → 2): Cleanup: 1 ← cleanup dari Effect sebelumnya (count=1) Setup: 2 ← setup Effect baru (count=2) // Kalau komponen unmount setelah ini: Cleanup: 2 ← cleanup terakhir

Penjelasan:

  • Setiap cleanup "ingat" nilai count dari render di mana dia dibuat (closure)
  • Cleanup selalu jalan SEBELUM setup baru
  • Cleanup terakhir jalan saat unmount

Challenge 2: Koneksi yang Reconnect Otomatis

Bikin komponen yang connect ke "server" (simulasi). Kalau koneksi putus, otomatis reconnect setelah 3 detik. Tapi kalau user ganti room, cancel reconnect dan connect ke room baru.

Hint: Pakai ref buat simpen timeout ID reconnect. Cleanup harus cancel timeout.

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

// Simulasi koneksi yang bisa putus
function buatKoneksi(roomId) {
  let onDisconnect = null;
  let connected = false;
  
  return {
    connect() {
      connected = true;
      console.log(`✅ Connected ke "${roomId}"`);
      // Simulasi: putus random setelah 5-10 detik
      setTimeout(() => {
        if (connected && onDisconnect) {
          connected = false;
          console.log(`💥 Koneksi ke "${roomId}" putus!`);
          onDisconnect();
        }
      }, 5000 + Math.random() * 5000);
    },
    disconnect() {
      connected = false;
      console.log(`❌ Disconnected dari "${roomId}"`);
    },
    onDisconnect(callback) {
      onDisconnect = callback;
    }
  };
}

function ChatRoom({ roomId }) {
  const [status, setStatus] = useState('connecting...');
  const reconnectTimerRef = useRef(null);
  
  useEffect(() => {
    let aktif = true;
    
    function connect() {
      if (!aktif) return;
      
      setStatus('connecting...');
      const koneksi = buatKoneksi(roomId);
      
      koneksi.onDisconnect(() => {
        if (!aktif) return;
        setStatus('disconnected. Reconnecting in 3s...');
        
        // Auto-reconnect setelah 3 detik
        reconnectTimerRef.current = setTimeout(() => {
          if (aktif) connect(); // Recursive reconnect
        }, 3000);
      });
      
      koneksi.connect();
      setStatus('connected ✅');
      
      return koneksi;
    }
    
    const koneksi = connect();
    
    return () => {
      aktif = false;
      // Cancel pending reconnect
      if (reconnectTimerRef.current) {
        clearTimeout(reconnectTimerRef.current);
      }
      if (koneksi) koneksi.disconnect();
    };
  }, [roomId]);
  
  return (
    <div>
      <h3>Room: {roomId}</h3>
      <p>Status: {status}</p>
    </div>
  );
}

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

Penjelasan kunci:

  • Flag aktif mencegah update state setelah cleanup
  • reconnectTimerRef menyimpan timeout ID supaya bisa di-cancel
  • Cleanup cancel timeout DAN disconnect, memastikan nggak ada zombie connection
  • Saat user ganti room: cleanup jalan → cancel reconnect lama → disconnect → connect ke room baru

Challenge 3: Effect dengan Conditional Dependency

Bikin komponen yang polling data setiap N detik, tapi CUMA kalau tab browser aktif (visible). Kalau tab di-hide, stop polling. Kalau tab aktif lagi, resume.

Hint: Pakai document.visibilityState dan event visibilitychange.

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

function SmartPolling({ url, intervalMs = 5000 }) {
  const [data, setData] = useState(null);
  const [tabAktif, setTabAktif] = useState(!document.hidden);
  const [lastFetch, setLastFetch] = useState(null);
  
  // Effect 1: Track visibility tab
  useEffect(() => {
    function handleVisibility() {
      setTabAktif(!document.hidden);
    }
    
    document.addEventListener('visibilitychange', handleVisibility);
    return () => document.removeEventListener('visibilitychange', handleVisibility);
  }, []);
  
  // Effect 2: Polling (cuma kalau tab aktif)
  useEffect(() => {
    if (!tabAktif) {
      console.log('⏸ Polling paused (tab hidden)');
      return; // Nggak setup interval kalau tab hidden
    }
    
    console.log('▶️ Polling started');
    let aktif = true;
    
    async function fetchData() {
      try {
        const res = await fetch(url);
        const json = await res.json();
        if (aktif) {
          setData(json);
          setLastFetch(new Date().toLocaleTimeString());
        }
      } catch (err) {
        console.error('Fetch error:', err);
      }
    }
    
    fetchData(); // Fetch langsung
    const intervalId = setInterval(fetchData, intervalMs);
    
    return () => {
      aktif = false;
      clearInterval(intervalId);
      console.log('⏹ Polling stopped');
    };
  }, [url, intervalMs, tabAktif]);
  // tabAktif di dependency: Effect restart saat tab aktif/hidden
  
  return (
    <div>
      <p>Tab: {tabAktif ? '👁 Aktif' : '😴 Hidden'}</p>
      <p>Data: {JSON.stringify(data)}</p>
      <p>Last fetch: {lastFetch || 'belum'}</p>
    </div>
  );
}

Penjelasan:

  • Effect 1 track status tab (reactive value)
  • Effect 2 depend pada tabAktif. Saat tab hidden → cleanup (stop interval). Saat tab aktif → setup (start interval).
  • Ini contoh bagus "berpikir dalam sinkronisasi": "Polling harus aktif selama tab visible dan URL/interval nggak berubah"

Sudah paham materi ini?

Tandai sebagai selesai untuk melacak progress-mu.