Bab 3: Sinkronisasi dengan Effects
⏱ 5 menit bacaApa 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.
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
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 jalanTiga bagian penting:
- Fungsi effect: kode yang mau dijalanin
- Cleanup function (return): bersih-bersih sebelum effect jalan lagi
- Dependency array: daftar nilai yang kalau berubah, effect jalan ulang
Dependency Array: Tiga Variasi
Variasi 1: Tanpa Array (Jalan Setiap Render)
useEffect(() => {
console.log('Jalan setiap kali komponen render');
});
// Tanpa [] = jalan setiap render. Jarang dipakai, biasanya bug.Variasi 2: Array Kosong [] (Jalan Sekali Saat Mount)
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 unmountVariasi 3: Array dengan Dependencies (Jalan Saat Dependency Berubah)
useEffect(() => {
console.log(`roomId berubah jadi: ${roomId}`);
return () => {
console.log(`Disconnect dari room: ${roomId}`);
};
}, [roomId]);
// [roomId] = jalan saat mount DAN setiap kali roomId berubahAnalogi: 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:
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":
- Cleanup jalan:
❌ Terputus dari room "umum" - 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
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
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
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:
- Cleanup jalan →
dibatalkan = truebuat fetch lama - Effect baru jalan → fetch baru dengan
dibatalkan = false - Kalau fetch lama selesai, dia cek
dibatalkandan nggak update state
Contoh 5: WebSocket
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?
| Aspek | Event Handler | Effect |
|---|---|---|
| Kapan jalan | Saat user melakukan aksi spesifik | Saat komponen perlu sinkronisasi |
| Trigger | Klik, ketik, submit, hover | Render (mount/update) |
| Contoh | Kirim form, tambah item | Koneksi API, subscribe event |
| Analogi | Pelayan nerima pesanan | AC otomatis nyala saat suhu naik |
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:
useEffect(() => {
console.log('Connect'); // Muncul 2x di development!
return () => {
console.log('Disconnect'); // Cleanup juga jalan
};
}, []);
// Output di console (development):
// Connect
// Disconnect
// ConnectJangan 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
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
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)
// ❌ 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
// ❌ 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
// ❌ 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
// ❌ 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
// ❌ 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
useEffectbuat sinkronisasi komponen dengan sistem luar (API, DOM, timer, dll)- Dependency array kontrol kapan Effect jalan ulang
- Cleanup function bersihkan resource saat Effect jalan ulang atau komponen unmount
- Event handler buat respons ke aksi user, Effect buat sinkronisasi otomatis
- Selalu handle race condition di fetch (flag
dibatalkan) - 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
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
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
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. Saattrue, bikin interval. Saatfalse, cleanup hapus interval. - Pakai updater function
setSisaWaktu(prev => ...)supaya nggak perlusisaWaktudi dependency array. - Cleanup otomatis jalan saat
sedangJalanberubah daritruekefalse(pause).
Sudah paham materi ini?
Tandai sebagai selesai untuk melacak progress-mu.