Bab 6: Memisahkan Event dari Effects

4 menit baca

Masalah: 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:

jsx
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 tema masuk dependency → setiap ganti tema, koneksi di-reset (disconnect + reconnect). Nggak masuk akal!
  • Kalau tema nggak 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.
jsx
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.

jsx
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:

  1. useEffectEvent bikin fungsi yang selalu "lihat" nilai terbaru dari closure-nya
  2. Fungsi ini bisa dipanggil dari dalam Effect
  3. Tapi dia nggak dianggap dependency oleh Effect
  4. Jadi Effect nggak re-run saat tema berubah

Workaround Tanpa useEffectEvent

Karena useEffectEvent masih experimental, ini beberapa workaround yang bisa kamu pakai sekarang:

Workaround 1: Pakai Ref

jsx
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?

  • temaRef itu ref, jadi nggak perlu masuk dependency array
  • Ref di-update setiap render, jadi .current selalu punya nilai terbaru
  • Effect nggak re-run saat tema berubah, tapi saat callback dipanggil, dia baca temaRef.current yang sudah ter-update

Workaround 2: Pisahkan Effect

Kadang solusinya simpel: pisahkan jadi dua Effect terpisah.

jsx
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

jsx
// 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

jsx
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 logAktif terbaru 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

jsx
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

jsx
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

jsx
// ❌ 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

jsx
// ❌ 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!
jsx
// ✅ 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

jsx
// ❌ 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

  1. Event handler = respons ke aksi user spesifik (klik, ketik, submit)
  2. Effect = sinkronisasi dengan sistem luar (koneksi, subscription)
  3. Effect Event / Ref pattern = baca nilai terbaru di dalam Effect tanpa trigger re-run
  4. Jangan suppress linter warning. Pakai ref pattern kalau butuh nilai non-reactive di Effect.
  5. Pisahkan Effect berdasarkan "apa yang harus bikin dia re-run"
  6. useEffectEvent masih 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
jsx
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
jsx
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
jsx
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.