Bab 5: Lifecycle dari Reactive Effects
⏱ 5 menit bacaCara Berpikir yang Benar tentang Effect
Kebanyakan pemula mikir Effect itu kayak lifecycle method di class component:
componentDidMount→useEffect(..., [])componentDidUpdate→useEffect(..., [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:
- Start (setup): mulai sinkronisasi
- Stop (cleanup): berhenti sinkronisasi
useEffect(() => {
// === START: mulai sinkronisasi ===
const koneksi = buatKoneksi(roomId);
koneksi.connect();
// === STOP: berhenti sinkronisasi ===
return () => {
koneksi.disconnect();
};
}, [roomId]);Timeline:
- Komponen mount dengan
roomId = "umum"→ Effect START (connect ke "umum") roomIdberubah jadi"teknologi"→ Effect STOP (disconnect dari "umum") → Effect START (connect ke "teknologi")roomIdberubah jadi"random"→ Effect STOP (disconnect dari "teknologi") → Effect START (connect ke "random")- 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.
function ChatRoom({ roomId }) {
useEffect(() => {
const koneksi = buatKoneksi(roomId);
koneksi.connect();
return () => koneksi.disconnect();
}, [roomId]);
return <h1>Room: {roomId}</h1>;
}Render 1 (roomId = "umum"):
// 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 pertamaRender 2 (roomId = "teknologi"):
// 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:
- Cleanup render 1:
disconnect dari "umum" - 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.
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
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
.currentbisa berubah tanpa trigger render) - Setter function dari useState (stabil antar render)
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).
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:
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"
// ❌ 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
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?
roomIdberubah → unsubscribe lama, subscribe baru (room beda)userIdberubah → unsubscribe lama, subscribe baru (user beda)notifAktifberubah → unsubscribe lama, subscribe baru (atau skip kalau false)
Setiap perubahan dependency = stop + start ulang. Ini memastikan sinkronisasi selalu benar.
Contoh: Interval yang Berubah
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
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):
// 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
aktifbuat 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
// ❌ 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
// ❌ 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
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
// 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
- Jangan pikir lifecycle, pikir sinkronisasi
- Effect punya dua fase: start (setup) dan stop (cleanup)
- Cleanup jalan sebelum Effect berikutnya, bukan cuma saat unmount
- Setiap render "bikin" Effect baru, tapi React cuma jalanin kalau dependency berubah
- Reactive values (props, state, turunannya) harus masuk dependency array
- Satu Effect = satu tanggung jawab sinkronisasi. Jangan campur!
- 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:
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
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
aktifmencegah update state setelah cleanup reconnectTimerRefmenyimpan 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
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.