Bab 6: Memisahkan Event dari Effects
⏱ 4 menit bacaMasalah: Logic yang "Setengah Reactive"
Di bab sebelumnya, kamu belajar bahwa semua reactive values yang dipakai di Effect harus masuk dependency array. Tapi kadang ada situasi di mana kamu pakai nilai reactive di dalam Effect, tapi nggak mau Effect re-run saat nilai itu berubah.
Bayangin kamu bikin fitur notifikasi chat:
function ChatRoom({ roomId, tema }) {
useEffect(() => {
const koneksi = buatKoneksi(roomId);
koneksi.connect();
koneksi.onPesanMasuk((pesan) => {
// Tampilkan notifikasi dengan tema saat ini
tampilkanNotifikasi(pesan, tema);
});
return () => koneksi.disconnect();
}, [roomId, tema]); // 🤔 Harus masukin tema?
}Dilema:
- Kalau
temamasuk dependency → setiap ganti tema, koneksi di-reset (disconnect + reconnect). Nggak masuk akal! - Kalau
temanggak masuk dependency → linter warning, dan notifikasi pakai tema yang basi (stale)
Ini masalah "logic yang setengah reactive". Koneksi harus reactive terhadap roomId, tapi notifikasi cuma perlu baca tema tanpa bikin Effect re-run.
Analogi: Ojol dan Alamat Tujuan
Bayangin kamu pesan ojol (Effect = perjalanan):
- Alamat tujuan (roomId) = kalau berubah, perjalanan harus di-cancel dan mulai ulang. Ini reactive.
- Playlist musik yang kamu dengerin selama perjalanan (tema) = kalau ganti lagu, perjalanan nggak perlu di-cancel. Kamu cuma perlu "baca" lagu yang sedang diputar saat mau nyanyi.
Kamu butuh cara buat "baca" nilai terbaru tanpa bikin perjalanan restart.
Event Handler vs Effect: Review
Sebelum masuk solusi, mari review perbedaan fundamental:
Event Handler:
- Jalan karena aksi spesifik dari user
- "User klik tombol kirim" → kirim pesan
- Nggak reactive. Nggak jalan ulang otomatis.
Effect:
- Jalan karena perlu sinkronisasi dengan sistem luar
- "roomId berubah" → reconnect ke room baru
- Reactive. Jalan ulang saat dependency berubah.
function ChatRoom({ roomId }) {
const [pesan, setPesan] = useState('');
// EVENT HANDLER: respons ke aksi user
function handleKirim() {
kirimPesan(roomId, pesan);
// Ini jalan HANYA saat user klik "Kirim"
}
// EFFECT: sinkronisasi dengan sistem luar
useEffect(() => {
const koneksi = buatKoneksi(roomId);
koneksi.connect();
return () => koneksi.disconnect();
// Ini jalan OTOMATIS saat roomId berubah
}, [roomId]);
return (
<div>
<input value={pesan} onChange={e => setPesan(e.target.value)} />
<button onClick={handleKirim}>Kirim</button>
</div>
);
}Konsep: useEffectEvent (Experimental)
React sedang mengembangkan hook baru bernama useEffectEvent yang menyelesaikan masalah "logic setengah reactive". Meskipun masih experimental, konsepnya penting dipahami.
Ide dasarnya: Bungkus logic yang "non-reactive" di dalam Effect Event. Effect Event selalu "lihat" nilai terbaru tanpa bikin Effect re-run.
import { useEffect, useEffectEvent } from 'react'; // experimental!
function ChatRoom({ roomId, tema }) {
// Effect Event: selalu punya akses ke tema terbaru
// tapi NGGAK bikin Effect re-run saat tema berubah
const onPesanMasuk = useEffectEvent((pesan) => {
tampilkanNotifikasi(pesan, tema); // Selalu pakai tema terbaru
});
useEffect(() => {
const koneksi = buatKoneksi(roomId);
koneksi.connect();
koneksi.onPesanMasuk((pesan) => {
onPesanMasuk(pesan); // Panggil Effect Event
});
return () => koneksi.disconnect();
}, [roomId]); // tema NGGAK perlu di sini!
// Koneksi cuma di-reset saat roomId berubah. Sempurna!
}Cara kerja:
useEffectEventbikin fungsi yang selalu "lihat" nilai terbaru dari closure-nya- Fungsi ini bisa dipanggil dari dalam Effect
- Tapi dia nggak dianggap dependency oleh Effect
- Jadi Effect nggak re-run saat
temaberubah
Workaround Tanpa useEffectEvent
Karena useEffectEvent masih experimental, ini beberapa workaround yang bisa kamu pakai sekarang:
Workaround 1: Pakai Ref
import { useState, useEffect, useRef } from 'react';
function ChatRoom({ roomId, tema }) {
// Simpen tema di ref (nggak trigger re-run Effect)
const temaRef = useRef(tema);
// Update ref setiap render
useEffect(() => {
temaRef.current = tema;
});
useEffect(() => {
const koneksi = buatKoneksi(roomId);
koneksi.connect();
koneksi.onPesanMasuk((pesan) => {
// Baca dari ref, selalu dapet nilai terbaru
tampilkanNotifikasi(pesan, temaRef.current);
});
return () => koneksi.disconnect();
}, [roomId]); // tema nggak perlu di dependency!
return <h2>Room: {roomId}</h2>;
}Kenapa ini jalan?
temaRefitu ref, jadi nggak perlu masuk dependency array- Ref di-update setiap render, jadi
.currentselalu punya nilai terbaru - Effect nggak re-run saat tema berubah, tapi saat callback dipanggil, dia baca
temaRef.currentyang sudah ter-update
Workaround 2: Pisahkan Effect
Kadang solusinya simpel: pisahkan jadi dua Effect terpisah.
function ChatRoom({ roomId, tema }) {
const [pesanMasuk, setPesanMasuk] = useState([]);
// Effect 1: Koneksi (reactive terhadap roomId)
useEffect(() => {
const koneksi = buatKoneksi(roomId);
koneksi.connect();
koneksi.onPesanMasuk((pesan) => {
setPesanMasuk(prev => [...prev, pesan]);
});
return () => koneksi.disconnect();
}, [roomId]);
// Effect 2: Notifikasi (reactive terhadap pesanMasuk DAN tema)
useEffect(() => {
if (pesanMasuk.length > 0) {
const pesanTerakhir = pesanMasuk[pesanMasuk.length - 1];
tampilkanNotifikasi(pesanTerakhir, tema);
}
}, [pesanMasuk, tema]);
return <h2>Room: {roomId}</h2>;
}Ini nggak sempurna (notifikasi juga muncul saat tema berubah), tapi kadang cukup.
Workaround 3: Custom Hook dengan Ref Pattern
// Custom hook yang bikin "stable callback"
function useStableCallback(callback) {
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
});
// Return fungsi yang stabil (referensi nggak berubah)
return useRef((...args) => callbackRef.current(...args)).current;
}
function ChatRoom({ roomId, tema }) {
// handlePesanMasuk selalu punya akses ke tema terbaru
// tapi referensinya stabil (nggak berubah antar render)
const handlePesanMasuk = useStableCallback((pesan) => {
tampilkanNotifikasi(pesan, tema);
});
useEffect(() => {
const koneksi = buatKoneksi(roomId);
koneksi.connect();
koneksi.onPesanMasuk(handlePesanMasuk);
return () => koneksi.disconnect();
}, [roomId, handlePesanMasuk]); // handlePesanMasuk stabil, jadi nggak trigger re-run
return <h2>Room: {roomId}</h2>;
}Contoh Lengkap: Logger yang Nggak Bikin Reconnect
import { useState, useEffect, useRef } from 'react';
function ChatApp() {
const [roomId, setRoomId] = useState('umum');
const [logAktif, setLogAktif] = useState(true);
const [pesanMasuk, setPesanMasuk] = useState([]);
// Ref buat nilai yang mau dibaca di Effect tanpa trigger re-run
const logAktifRef = useRef(logAktif);
useEffect(() => {
logAktifRef.current = logAktif;
});
useEffect(() => {
console.log(`🔌 Connecting ke room: ${roomId}`);
// Simulasi koneksi
const intervalId = setInterval(() => {
const pesanBaru = `[${roomId}] Pesan pada ${new Date().toLocaleTimeString()}`;
setPesanMasuk(prev => [...prev, pesanBaru]);
// Baca dari ref, bukan dari closure
if (logAktifRef.current) {
console.log(`📝 Log: ${pesanBaru}`);
}
}, 3000);
return () => {
console.log(`🔌 Disconnecting dari room: ${roomId}`);
clearInterval(intervalId);
};
}, [roomId]); // logAktif NGGAK di sini! Ganti log nggak bikin reconnect
return (
<div>
<div>
<label>Room: </label>
<select value={roomId} onChange={e => setRoomId(e.target.value)}>
<option value="umum">Umum</option>
<option value="teknologi">Teknologi</option>
</select>
<label>
<input
type="checkbox"
checked={logAktif}
onChange={e => setLogAktif(e.target.checked)}
/>
Log aktif
</label>
</div>
<div style={{ height: '200px', overflow: 'auto', border: '1px solid gray' }}>
{pesanMasuk.map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
</div>
);
}Apa yang terjadi:
- Ganti room → disconnect lama, connect baru (benar!)
- Toggle log → NGGAK disconnect/reconnect (benar!)
- Log tetap baca nilai
logAktifterbaru lewat ref
Kapan Logic Harus di Event Handler vs Effect
Ini decision tree yang lebih detail:
Apakah logic ini HARUS jalan sebagai respons ke aksi user spesifik?
│
├── YA: "User klik kirim" → kirim pesan
│ └── Taruh di EVENT HANDLER
│
└── TIDAK: Logic perlu jalan otomatis berdasarkan kondisi
│
├── Apakah logic ini perlu SINKRONISASI terus-menerus?
│ │
│ ├── YA: "Selama di room X, tetap terhubung"
│ │ └── Taruh di EFFECT
│ │
│ └── TIDAK: "Saat pesan masuk, tampilkan notifikasi"
│ └── Ini EFFECT EVENT (atau workaround dengan ref)
│
└── Apakah ini bisa dihitung dari state/props?
└── YA → Hitung langsung (bab sebelumnya)
Contoh: Shopping Cart dengan Analytics
import { useState, useEffect, useRef } from 'react';
function ShoppingCart() {
const [items, setItems] = useState([]);
const [tema, setTema] = useState('light');
// Ref buat tema (nggak mau analytics trigger reconnect)
const temaRef = useRef(tema);
useEffect(() => { temaRef.current = tema; });
// Effect: sync cart ke server (reactive terhadap items)
useEffect(() => {
if (items.length === 0) return;
// Sync ke server setiap items berubah
const timeoutId = setTimeout(() => {
fetch('/api/cart/sync', {
method: 'POST',
body: JSON.stringify({ items, tema: temaRef.current })
});
console.log(`Synced ${items.length} items (tema: ${temaRef.current})`);
}, 1000); // Debounce 1 detik
return () => clearTimeout(timeoutId);
}, [items]); // Cuma sync ulang kalau items berubah, bukan tema
// EVENT HANDLER: respons ke aksi user
function handleTambahItem(produk) {
setItems([...items, produk]);
// Analytics langsung di handler (aksi user)
analytics.track('item_ditambahkan', { produk: produk.nama });
}
function handleHapusItem(index) {
setItems(items.filter((_, i) => i !== index));
analytics.track('item_dihapus');
}
return (
<div className={`cart-${tema}`}>
<select value={tema} onChange={e => setTema(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<button onClick={() => handleTambahItem({ nama: 'Produk', harga: 10000 })}>
Tambah Item
</button>
<ul>
{items.map((item, i) => (
<li key={i}>
{item.nama} - Rp{item.harga.toLocaleString()}
<button onClick={() => handleHapusItem(i)}>Hapus</button>
</li>
))}
</ul>
</div>
);
}Contoh: Form dengan Validasi Server
import { useState, useEffect, useRef } from 'react';
function FormRegistrasi() {
const [username, setUsername] = useState('');
const [usernameValid, setUsernameValid] = useState(null);
const [sedangCek, setSedangCek] = useState(false);
const [tampilkanError, setTampilkanError] = useState(true);
// Ref buat setting tampilkan error
const tampilkanErrorRef = useRef(tampilkanError);
useEffect(() => { tampilkanErrorRef.current = tampilkanError; });
// Effect: cek ketersediaan username (reactive terhadap username)
useEffect(() => {
if (username.length < 3) {
setUsernameValid(null);
return;
}
let aktif = true;
setSedangCek(true);
const timeoutId = setTimeout(async () => {
try {
const res = await fetch(`/api/cek-username?u=${username}`);
const data = await res.json();
if (aktif) {
setUsernameValid(data.tersedia);
setSedangCek(false);
// Baca setting terbaru dari ref
if (!data.tersedia && tampilkanErrorRef.current) {
console.log(`Username "${username}" sudah dipakai`);
}
}
} catch (err) {
if (aktif) setSedangCek(false);
}
}, 500);
return () => {
aktif = false;
clearTimeout(timeoutId);
};
}, [username]); // Cuma depend pada username, bukan tampilkanError
return (
<div>
<input
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="Username (min 3 karakter)"
/>
{sedangCek && <span>⏳ Mengecek...</span>}
{usernameValid === true && <span style={{ color: 'green' }}>✅ Tersedia</span>}
{usernameValid === false && <span style={{ color: 'red' }}>❌ Sudah dipakai</span>}
<label>
<input
type="checkbox"
checked={tampilkanError}
onChange={e => setTampilkanError(e.target.checked)}
/>
Tampilkan error di console
</label>
</div>
);
}⚠️ Jebakan
Jebakan 1: Suppress Linter Warning
// ❌ JANGAN suppress linter!
useEffect(() => {
tampilkanNotifikasi(pesan, tema);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pesan]); // tema sengaja nggak dimasukin
// Ini "jalan" tapi tema bakal stale!
// Pakai ref pattern sebagai gantinya.Jebakan 2: Semua Logic di Effect
// ❌ Terlalu banyak logic di Effect
useEffect(() => {
const koneksi = buatKoneksi(roomId);
koneksi.connect();
koneksi.onPesanMasuk((pesan) => {
setPesanList(prev => [...prev, pesan]);
updateBadge(pesanList.length + 1); // Non-reactive logic
playSound(volumeSetting); // Non-reactive logic
logToAnalytics(userId, roomId); // Non-reactive logic
});
return () => koneksi.disconnect();
}, [roomId, volumeSetting, userId]); // Terlalu banyak dependency!
// Ganti volume → reconnect? Nggak masuk akal!// ✅ Pisahkan reactive dan non-reactive
function ChatRoom({ roomId, volumeSetting, userId }) {
const volumeRef = useRef(volumeSetting);
const userIdRef = useRef(userId);
useEffect(() => { volumeRef.current = volumeSetting; });
useEffect(() => { userIdRef.current = userId; });
useEffect(() => {
const koneksi = buatKoneksi(roomId);
koneksi.connect();
koneksi.onPesanMasuk((pesan) => {
setPesanList(prev => [...prev, pesan]);
playSound(volumeRef.current); // Baca dari ref
logToAnalytics(userIdRef.current, roomId); // Baca dari ref
});
return () => koneksi.disconnect();
}, [roomId]); // Cuma roomId! Bersih!
}Jebakan 3: Bingung Kapan Pakai Handler vs Effect
// ❌ Kirim pesan di Effect (salah!)
function Chat({ roomId }) {
const [pesan, setPesan] = useState('');
const [kirim, setKirim] = useState(false);
useEffect(() => {
if (kirim) {
kirimPesan(roomId, pesan);
setKirim(false);
}
}, [kirim]); // Ribet dan rawan bug
return <button onClick={() => setKirim(true)}>Kirim</button>;
}
// ✅ Kirim pesan di handler (benar!)
function Chat({ roomId }) {
const [pesan, setPesan] = useState('');
function handleKirim() {
kirimPesan(roomId, pesan); // Langsung!
setPesan('');
}
return <button onClick={handleKirim}>Kirim</button>;
}Aturan: Kalau ada trigger spesifik dari user (klik, submit, gesture), itu event handler. Titik.
Ringkasan
- Event handler = respons ke aksi user spesifik (klik, ketik, submit)
- Effect = sinkronisasi dengan sistem luar (koneksi, subscription)
- Effect Event / Ref pattern = baca nilai terbaru di dalam Effect tanpa trigger re-run
- Jangan suppress linter warning. Pakai ref pattern kalau butuh nilai non-reactive di Effect.
- Pisahkan Effect berdasarkan "apa yang harus bikin dia re-run"
useEffectEventmasih experimental, tapi konsepnya penting
🏋️ Challenge
Challenge 1: Timer dengan Volume yang Bisa Diubah
Bikin timer yang bunyi setiap detik. Volume suara bisa diubah user kapan aja, tapi mengubah volume NGGAK boleh reset timer.
Hint: Volume harus dibaca dari ref di dalam Effect.
Lihat Solusi
import { useState, useEffect, useRef } from 'react';
function TimerDenganVolume() {
const [detik, setDetik] = useState(0);
const [sedangJalan, setSedangJalan] = useState(false);
const [volume, setVolume] = useState(50);
// Ref buat volume (nggak mau reset timer saat volume berubah)
const volumeRef = useRef(volume);
useEffect(() => {
volumeRef.current = volume;
});
useEffect(() => {
if (!sedangJalan) return;
console.log('⏱ Timer started');
const intervalId = setInterval(() => {
setDetik(d => d + 1);
// Baca volume terbaru dari ref
const vol = volumeRef.current;
console.log(`🔊 Beep! (volume: ${vol}%)`);
// Simulasi play sound dengan volume tertentu
// playBeep(vol / 100);
}, 1000);
return () => {
console.log('⏱ Timer stopped');
clearInterval(intervalId);
};
}, [sedangJalan]); // Cuma depend pada sedangJalan, bukan volume!
return (
<div>
<h2>Timer: {detik} detik</h2>
<button onClick={() => setSedangJalan(!sedangJalan)}>
{sedangJalan ? '⏸ Pause' : '▶️ Start'}
</button>
<button onClick={() => setDetik(0)}>🔄 Reset</button>
<div>
<label>Volume: {volume}%</label>
<input
type="range"
min="0"
max="100"
value={volume}
onChange={e => setVolume(Number(e.target.value))}
/>
<p style={{ fontSize: '12px', color: 'gray' }}>
(Mengubah volume NGGAK reset timer)
</p>
</div>
</div>
);
}Penjelasan: Volume disimpan di ref. Effect cuma depend pada sedangJalan. Jadi geser slider volume nggak bikin interval di-clear dan dibuat ulang. Timer tetap jalan mulus, tapi setiap beep pakai volume terbaru.
Challenge 2: Notifikasi Chat dengan Preferensi User
Bikin komponen chat yang:
- Connect ke room (reactive terhadap roomId)
- Tampilkan notifikasi saat pesan masuk
- User bisa toggle notifikasi on/off TANPA bikin reconnect
Hint: Status notifikasi (on/off) harus di ref.
Lihat Solusi
import { useState, useEffect, useRef } from 'react';
// Simulasi server chat
function simulasiChat(roomId, onPesan) {
console.log(`✅ Connected ke room: ${roomId}`);
const id = setInterval(() => {
const pesanRandom = [
'Halo semua!',
'Ada yang bisa bantu?',
'Lagi ngapain nih?',
'Siapa di sini?'
];
const pesan = pesanRandom[Math.floor(Math.random() * pesanRandom.length)];
onPesan({ user: `User${Math.floor(Math.random() * 100)}`, teks: pesan });
}, 4000);
return {
disconnect() {
clearInterval(id);
console.log(`❌ Disconnected dari room: ${roomId}`);
}
};
}
function ChatDenganNotifikasi() {
const [roomId, setRoomId] = useState('umum');
const [notifAktif, setNotifAktif] = useState(true);
const [pesanList, setPesanList] = useState([]);
// Ref buat preferensi notifikasi
const notifAktifRef = useRef(notifAktif);
useEffect(() => {
notifAktifRef.current = notifAktif;
});
// Effect: koneksi chat (cuma reactive terhadap roomId)
useEffect(() => {
setPesanList([]); // Reset pesan saat ganti room
const koneksi = simulasiChat(roomId, (pesan) => {
setPesanList(prev => [...prev, pesan]);
// Baca preferensi terbaru dari ref
if (notifAktifRef.current) {
// Simulasi notifikasi browser
console.log(`🔔 Notifikasi: ${pesan.user} bilang "${pesan.teks}"`);
}
});
return () => koneksi.disconnect();
}, [roomId]); // notifAktif NGGAK di sini!
return (
<div>
<div style={{ marginBottom: '10px' }}>
<select value={roomId} onChange={e => setRoomId(e.target.value)}>
<option value="umum">Room Umum</option>
<option value="teknologi">Room Teknologi</option>
<option value="random">Room Random</option>
</select>
<label style={{ marginLeft: '10px' }}>
<input
type="checkbox"
checked={notifAktif}
onChange={e => setNotifAktif(e.target.checked)}
/>
🔔 Notifikasi {notifAktif ? 'ON' : 'OFF'}
</label>
<span style={{ fontSize: '12px', color: 'gray', marginLeft: '5px' }}>
(toggle ini NGGAK bikin reconnect)
</span>
</div>
<div style={{ height: '200px', overflow: 'auto', border: '1px solid #ccc', padding: '10px' }}>
{pesanList.length === 0 && <p style={{ color: 'gray' }}>Menunggu pesan...</p>}
{pesanList.map((p, i) => (
<p key={i}><strong>{p.user}:</strong> {p.teks}</p>
))}
</div>
</div>
);
}Verifikasi: Buka console. Ganti room → lihat "Disconnected" + "Connected" (reconnect). Toggle notifikasi → NGGAK ada disconnect/connect (benar!).
Challenge 3: Auto-Save dengan Debounce yang Nggak Reset Saat Setting Berubah
Bikin editor teks yang auto-save ke "server" setiap 2 detik setelah user berhenti ngetik. Ada setting "format" (markdown/plain) yang bisa diubah. Mengubah format NGGAK boleh reset debounce timer.
Hint: Format disimpan di ref. Debounce timer di Effect yang depend pada konten.
Lihat Solusi
import { useState, useEffect, useRef } from 'react';
function EditorAutoSave() {
const [konten, setKonten] = useState('');
const [format, setFormat] = useState('markdown');
const [statusSimpan, setStatusSimpan] = useState('');
const [jumlahSimpan, setJumlahSimpan] = useState(0);
// Ref buat format (nggak mau reset debounce saat format berubah)
const formatRef = useRef(format);
useEffect(() => {
formatRef.current = format;
});
// Effect: auto-save dengan debounce (reactive terhadap konten saja)
useEffect(() => {
if (konten === '') return;
setStatusSimpan('⏳ Menunggu...');
const timeoutId = setTimeout(() => {
// Baca format terbaru dari ref
const formatSaatIni = formatRef.current;
// Simulasi save ke server
console.log(`💾 Saving (format: ${formatSaatIni}):`, konten.substring(0, 50));
setStatusSimpan(`✅ Tersimpan sebagai ${formatSaatIni}`);
setJumlahSimpan(prev => prev + 1);
setTimeout(() => setStatusSimpan(''), 2000);
}, 2000); // Debounce 2 detik
return () => {
clearTimeout(timeoutId);
};
}, [konten]); // Cuma depend pada konten! Format nggak di sini.
return (
<div>
<h2>Editor dengan Auto-Save</h2>
<div style={{ marginBottom: '10px' }}>
<label>Format: </label>
<select value={format} onChange={e => setFormat(e.target.value)}>
<option value="markdown">Markdown</option>
<option value="plain">Plain Text</option>
<option value="html">HTML</option>
</select>
<span style={{ fontSize: '12px', color: 'gray', marginLeft: '10px' }}>
(ganti format NGGAK reset timer save)
</span>
</div>
<textarea
value={konten}
onChange={e => setKonten(e.target.value)}
placeholder="Mulai menulis..."
rows={10}
style={{ width: '100%', fontFamily: format === 'markdown' ? 'monospace' : 'inherit' }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '5px' }}>
<span>{statusSimpan}</span>
<span style={{ color: 'gray', fontSize: '12px' }}>
Total save: {jumlahSimpan} | Format: {format}
</span>
</div>
</div>
);
}Penjelasan:
- Ketik teks → debounce timer mulai (2 detik)
- Ganti format di tengah-tengah → timer NGGAK reset (karena format bukan dependency)
- Saat timer selesai, save pakai format terbaru (dari ref)
- Ketik lagi sebelum 2 detik → timer reset (karena konten berubah = Effect re-run)
Sudah paham materi ini?
Tandai sebagai selesai untuk melacak progress-mu.