Bab 4: State Sebagai Snapshot

7 menit baca

Analogi: Foto, Bukan Video

Bayangin kamu foto pemandangan. Foto itu menangkap momen tepat saat kamu jepret. Kalau setelah foto diambil ada burung lewat, burung itu nggak muncul di foto. Foto itu "snapshot" — tangkapan satu momen yang nggak berubah.

State di React bekerja persis kayak gitu. Setiap kali komponen di-render, state-nya itu kayak foto. Nilainya tetap (fixed) selama render itu berlangsung. Mau kamu ngapain aja di dalam render itu, nilai state nggak berubah sampai render berikutnya.

Ini konsep yang sering bikin pemula bingung, tapi begitu kamu paham, banyak "bug misterius" yang tiba-tiba masuk akal.


State Nggak Berubah di Render yang Sama

Coba tebak, apa yang muncul di alert?

jsx
import { useState } from 'react';

function Counter() {
  const [angka, setAngka] = useState(0);

  function handleKlik() {
    setAngka(angka + 1);
    alert(angka); // Apa yang muncul?
  }

  return (
    <button onClick={handleKlik}>
      Angka: {angka}
    </button>
  );
}

Kalau angka awalnya 0 dan kamu klik tombol, alert menampilkan... 0! Bukan 1!

Kenapa? Karena setAngka(angka + 1) nggak langsung mengubah variabel angka. Variabel angka di render ini sudah "difoto" sebagai 0. Mau kamu panggil setAngka berapa kali pun, di render ini angka tetap 0.

Nilai baru (1) baru tersedia di render berikutnya.


Mental Model: Setiap Render Punya State Sendiri

Cara terbaik memahami ini: bayangkan setiap render sebagai "dunia" yang terpisah. Setiap dunia punya "foto" state-nya sendiri.

jsx
function Counter() {
  const [angka, setAngka] = useState(0);
  // ...
}

// Render 1: angka = 0 (foto pertama)
// Render 2: angka = 1 (foto kedua)
// Render 3: angka = 2 (foto ketiga)
// Setiap render punya "angka" sendiri yang nggak berubah

Bayangin kayak film yang terdiri dari frame-frame. Setiap frame itu satu render. Di frame 1, angka = 0. Di frame 2, angka = 1. Kamu nggak bisa mengubah isi frame yang sudah diambil.

Visualisasi

┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ RENDER 1 │ │ RENDER 2 │ │ RENDER 3 │ │ │ │ │ │ │ │ angka = 0 │ │ angka = 1 │ │ angka = 2 │ │ │ │ │ │ │ │ "Angka: 0" │ │ "Angka: 1" │ │ "Angka: 2" │ │ │ │ │ │ │ │ [Klik] → set(1)│ │ [Klik] → set(2)│ │ [Klik] → set(3)│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │ │ ▼ ▼ ▼ Trigger render 2 Trigger render 3 Trigger render 4

setState Nggak Mengubah Variabel Saat Ini

Ini yang paling penting dipahami. setState itu bukan "ubah variabel sekarang". Dia itu "minta React untuk render ulang dengan nilai baru nanti".

jsx
function ContohPenting() {
  const [pesan, setPesan] = useState('Halo');

  function handleKlik() {
    setPesan('Selamat tinggal');
    console.log(pesan); // Masih 'Halo'! Bukan 'Selamat tinggal'!
  }

  return (
    <div>
      <p>{pesan}</p>
      <button onClick={handleKlik}>Ubah Pesan</button>
    </div>
  );
}

Analoginya: kamu pesan makanan di restoran. Saat kamu bilang "Saya pesan nasi goreng" (setState), makanan belum langsung ada di meja. Kamu masih laper (nilai lama). Makanan baru datang nanti (render berikutnya).


Multiple setState dalam Satu Handler

Ini yang sering bikin bingung. Apa yang terjadi kalau panggil setState berkali-kali?

jsx
function CounterBingung() {
  const [angka, setAngka] = useState(0);

  function handleKlik() {
    setAngka(angka + 1); // angka = 0, jadi set ke 1
    setAngka(angka + 1); // angka MASIH 0! jadi set ke 1 lagi!
    setAngka(angka + 1); // angka MASIH 0! jadi set ke 1 lagi!
  }

  return (
    <div>
      <p>Angka: {angka}</p>
      <button onClick={handleKlik}>+3?</button>
    </div>
  );
}

Kamu mungkin berharap angka naik 3 setiap klik. Tapi kenyataannya cuma naik 1! Kenapa?

Karena di render ini, angka = 0. Itu "foto"-nya. Jadi ketiga setAngka(angka + 1) semuanya jadi setAngka(0 + 1) = setAngka(1). Kamu set ke 1 tiga kali, hasilnya tetap 1.

Ini kayak kamu foto papan tulis yang bertuliskan "0", lalu bilang:

  • "Ganti jadi 0 + 1" → 1
  • "Ganti jadi 0 + 1" → 1
  • "Ganti jadi 0 + 1" → 1

Kamu selalu baca dari foto yang sama (0), bukan dari hasil perubahan sebelumnya.

Solusinya? Pakai updater function! Tapi itu dibahas di bab berikutnya (Bab 5).


State dan setTimeout/setInterval

Di sinilah konsep snapshot jadi sangat jelas. Perhatiin:

jsx
function TimerContoh() {
  const [angka, setAngka] = useState(0);

  function handleKlik() {
    setAngka(angka + 1);
    
    setTimeout(() => {
      alert(angka); // Berapa yang muncul?
    }, 3000);
  }

  return (
    <div>
      <p>Angka: {angka}</p>
      <button onClick={handleKlik}>+1 lalu alert setelah 3 detik</button>
    </div>
  );
}

Kalau angka = 0 dan kamu klik:

  1. setAngka(0 + 1) → jadwalkan render dengan angka = 1
  2. setTimeout dijadwalkan dengan delay 3 detik
  3. Render baru terjadi, layar menampilkan "Angka: 1"
  4. 3 detik kemudian, alert muncul... menampilkan 0!

Kenapa 0? Karena fungsi di dalam setTimeout "menangkap" (capture) nilai angka saat handler dipanggil, yaitu 0. Meskipun layar sudah menampilkan 1, fungsi setTimeout masih "hidup di dunia render lama" di mana angka = 0.

Eksperimen Lebih Lanjut

jsx
function EksperimenSnapshot() {
  const [angka, setAngka] = useState(0);

  function handleKlik() {
    setAngka(5);

    setTimeout(() => {
      // Ini masih pakai angka dari render SAAT KLIK, bukan render terbaru
      setAngka(angka + 1); // angka = 0 (snapshot), jadi set ke 1, BUKAN 6!
    }, 3000);
  }

  return (
    <div>
      <p>Angka: {angka}</p>
      <button onClick={handleKlik}>Klik</button>
    </div>
  );
}

Alur:

  1. Klik saat angka = 0
  2. setAngka(5) → render baru, layar jadi "Angka: 5"
  3. 3 detik kemudian, setAngka(angka + 1) dijalankan
  4. Tapi angka di sini masih 0 (snapshot dari render saat klik!)
  5. Jadi setAngka(0 + 1) = setAngka(1)
  6. Layar berubah dari "Angka: 5" ke "Angka: 1"

Ini sering jadi sumber bug yang membingungkan!


Analogi Lengkap: Surat dan Pos

Bayangin kamu nulis surat ke temen. Di surat itu kamu tulis "Gue sekarang tinggal di Jakarta" (state saat ini). Setelah surat dikirim (setTimeout), kamu pindah ke Bandung (state berubah). Tapi surat yang sudah dikirim tetap bilang "Jakarta" — karena surat itu "snapshot" dari keadaan saat ditulis.

jsx
function AnalogiSurat() {
  const [kota, setKota] = useState('Jakarta');

  function handlePindah() {
    // Kirim "surat" (setTimeout) sebelum pindah
    setTimeout(() => {
      alert(`Surat bilang: Gue di ${kota}`); // Masih 'Jakarta'!
    }, 5000);

    // Pindah kota
    setKota('Bandung');
  }

  return (
    <div>
      <p>Kota: {kota}</p>
      <button onClick={handlePindah}>Pindah ke Bandung</button>
    </div>
  );
}

Event Handler "Milik" Render Tertentu

Setiap event handler yang dibuat di satu render "milik" render itu. Dia melihat state dari render di mana dia dibuat.

jsx
function ChatApp() {
  const [pesan, setPesan] = useState('');
  const [pesanTerkirim, setPesanTerkirim] = useState([]);

  function handleKirim() {
    // 'pesan' di sini adalah snapshot dari render saat tombol diklik
    setPesanTerkirim([...pesanTerkirim, pesan]);
    setPesan('');
    
    // Simulasi kirim ke server
    setTimeout(() => {
      console.log(`Terkirim ke server: "${pesan}"`);
      // 'pesan' di sini JUGA snapshot dari render saat tombol diklik
      // Bukan string kosong '' meskipun setPesan('') sudah dipanggil
    }, 2000);
  }

  return (
    <div>
      <input 
        value={pesan}
        onChange={(e) => setPesan(e.target.value)}
        placeholder="Ketik pesan..."
      />
      <button onClick={handleKirim}>Kirim</button>
      <ul>
        {pesanTerkirim.map((p, i) => <li key={i}>{p}</li>)}
      </ul>
    </div>
  );
}

Ini sebenarnya fitur yang berguna, bukan bug! Bayangkan kalau kamu kirim pesan "Halo", lalu langsung ketik pesan baru "Apa kabar". Kamu mau yang terkirim ke server itu "Halo" (pesan saat klik), bukan "Apa kabar" (pesan terbaru). Snapshot behavior memastikan ini bekerja dengan benar.


Substitusi Mental: Ganti Variabel dengan Nilainya

Trik untuk memahami kode React: ganti semua variabel state dengan nilainya di render tertentu.

jsx
function Counter() {
  const [angka, setAngka] = useState(0);

  function handleKlik() {
    setAngka(angka + 5);
    alert(angka);
  }

  return <button onClick={handleKlik}>+5</button>;
}

Di render pertama (angka = 0), kode di atas "sebenarnya" jadi:

jsx
// Render 1: angka = 0
function handleKlik() {
  setAngka(0 + 5);  // Minta render baru dengan angka = 5
  alert(0);          // Alert menampilkan 0
}

return <button onClick={handleKlik}>+5</button>;

Di render kedua (angka = 5):

jsx
// Render 2: angka = 5
function handleKlik() {
  setAngka(5 + 5);  // Minta render baru dengan angka = 10
  alert(5);          // Alert menampilkan 5
}

return <button onClick={handleKlik}>+5</button>;

Setiap render punya "versi" handler-nya sendiri dengan nilai state yang sudah "ditanam" (baked in).


Contoh Interaktif: Kirim Pesan dengan Delay

jsx
import { useState } from 'react';

function PesanDenganDelay() {
  const [penerima, setPenerima] = useState('Budi');
  const [pesan, setPesan] = useState('Halo!');

  function handleKirim() {
    // Capture snapshot saat ini
    const pesanSnapshot = pesan;
    const penerimaSnapshot = penerima;
    
    setTimeout(() => {
      // Ini pakai snapshot, bukan nilai terbaru
      alert(`Pesan "${pesanSnapshot}" terkirim ke ${penerimaSnapshot}`);
    }, 3000);
  }

  return (
    <div>
      <select value={penerima} onChange={(e) => setPenerima(e.target.value)}>
        <option value="Budi">Budi</option>
        <option value="Ani">Ani</option>
        <option value="Citra">Citra</option>
      </select>
      <input 
        value={pesan} 
        onChange={(e) => setPesan(e.target.value)} 
      />
      <button onClick={handleKirim}>Kirim (delay 3 detik)</button>
      <p>
        <i>Coba: klik Kirim, lalu langsung ganti penerima. 
        Pesan tetap terkirim ke penerima SAAT KLIK.</i>
      </p>
    </div>
  );
}

Coba ini:

  1. Pilih penerima "Budi", ketik "Halo"
  2. Klik "Kirim"
  3. LANGSUNG ganti penerima ke "Ani" dan pesan ke "Bye"
  4. Tunggu 3 detik...
  5. Alert tetap bilang: Pesan "Halo" terkirim ke Budi

Ini membuktikan bahwa handler "menangkap" state saat dia dipanggil, bukan state terbaru.


Kenapa React Didesain Begini?

Kamu mungkin mikir "Ini ribet amat, kenapa nggak langsung update aja?" Ada alasan bagus:

1. Konsistensi

Kalau state bisa berubah di tengah-tengah handler, kode jadi nggak predictable:

jsx
// Bayangkan kalau state langsung berubah (BUKAN cara React bekerja):
function handleKlik() {
  setAngka(angka + 1); // angka jadi 1
  // ... banyak kode lain ...
  console.log(angka);   // 1? 0? Tergantung timing? KACAU!
}

Dengan snapshot, kamu SELALU tau nilai state di handler itu apa. Nggak ada ambiguitas.

2. Batching yang Aman

React bisa mengelompokkan beberapa setState tanpa khawatir urutan bermasalah, karena semua setState di satu handler "melihat" state yang sama.

3. Concurrent Features

Di React 18+, ada fitur-fitur concurrent (seperti Suspense, Transitions) yang bergantung pada perilaku snapshot ini. Tanpa snapshot, fitur-fitur itu nggak mungkin ada.


Pola Umum: Menggunakan State Baru Segera

"Tapi gimana kalau gue BUTUH nilai baru segera setelah setState?"

Simpan di variabel lokal:

jsx
function ContohPraktis() {
  const [items, setItems] = useState([]);

  function handleTambah(itemBaru) {
    // Simpan nilai baru di variabel lokal
    const itemsUpdated = [...items, itemBaru];
    
    // Pakai variabel lokal untuk logika selanjutnya
    setItems(itemsUpdated);
    console.log('Total items sekarang:', itemsUpdated.length); // Benar!
    
    if (itemsUpdated.length >= 10) {
      alert('Keranjang penuh!');
    }
  }

  return (
    <div>
      <p>Items: {items.length}</p>
      <button onClick={() => handleTambah('Kopi')}>Tambah Kopi</button>
    </div>
  );
}

⚠️ Jebakan

Jebakan 1: Mengharapkan state langsung berubah

jsx
// ❌ Bug klasik
function Salah() {
  const [angka, setAngka] = useState(0);

  function handleKlik() {
    setAngka(angka + 1);
    // Ini MASIH 0, bukan 1!
    if (angka === 1) {
      alert('Satu!'); // Nggak akan pernah muncul di klik pertama!
    }
  }
}

// ✅ Pakai variabel lokal
function Benar() {
  const [angka, setAngka] = useState(0);

  function handleKlik() {
    const angkaBaru = angka + 1;
    setAngka(angkaBaru);
    if (angkaBaru === 1) {
      alert('Satu!'); // Sekarang bisa!
    }
  }
}

Jebakan 2: setState di setTimeout pakai nilai basi

jsx
// ❌ Bug: selalu increment dari 0
function Salah() {
  const [angka, setAngka] = useState(0);

  function handleKlik() {
    setTimeout(() => {
      setAngka(angka + 1); // angka selalu 0 (snapshot)!
    }, 3000);
  }
  // Kalau klik 5x cepat-cepat, hasilnya tetap 1, bukan 5
}

// ✅ Pakai updater function (dibahas di bab berikutnya)
function Benar() {
  const [angka, setAngka] = useState(0);

  function handleKlik() {
    setTimeout(() => {
      setAngka(prev => prev + 1); // Selalu pakai nilai terbaru
    }, 3000);
  }
  // Klik 5x = angka jadi 5 ✓
}

Jebakan 3: Loop dengan setState

jsx
// ❌ Ini nggak bikin angka jadi 5
function Salah() {
  const [angka, setAngka] = useState(0);

  function handleKlik() {
    for (let i = 0; i < 5; i++) {
      setAngka(angka + 1); // Semua jadi setAngka(0 + 1) = setAngka(1)
    }
    // Hasil: angka = 1, bukan 5!
  }
}

// ✅ Pakai updater function
function Benar() {
  const [angka, setAngka] = useState(0);

  function handleKlik() {
    for (let i = 0; i < 5; i++) {
      setAngka(prev => prev + 1); // Setiap iterasi pakai nilai terbaru
    }
    // Hasil: angka = 5 ✓
  }
}

Jebakan 4: Bingung antara state dan variabel lokal

jsx
function Bingung() {
  const [angka, setAngka] = useState(0);
  let tampilan = angka; // Variabel lokal, copy dari state

  function handleKlik() {
    tampilan = 99;      // Ini cuma ubah variabel lokal
    setAngka(angka + 1); // Ini yang trigger re-render
    // tampilan nggak ada hubungannya dengan apa yang ditampilkan
  }

  return <p>{tampilan}</p>; // Ini ikut state, bukan variabel lokal
}

🏋️ Challenge

Challenge 1: Prediksi Alert

Apa yang ditampilkan alert saat tombol diklik (angka awal = 0)?

jsx
function Quiz() {
  const [angka, setAngka] = useState(0);

  function handleKlik() {
    setAngka(angka + 5);
    setAngka(angka + 5);
    setAngka(angka + 5);
    alert(angka);
  }

  return <button onClick={handleKlik}>Klik</button>;
}
💡 Hint

Ingat: semua angka di handler ini adalah snapshot dari render saat ini (= 0). Dan alert juga pakai snapshot yang sama.

✅ Solusi

Alert menampilkan 0.

Dan setelah render berikutnya, angka di layar jadi 5 (bukan 15).

Penjelasan:

  • angka = 0 (snapshot)
  • setAngka(0 + 5) → jadwalkan render dengan 5
  • setAngka(0 + 5) → jadwalkan render dengan 5 (sama!)
  • setAngka(0 + 5) → jadwalkan render dengan 5 (sama!)
  • alert(0) → tampilkan 0 (snapshot)
  • React render ulang dengan angka = 5

Ketiga setState semuanya set ke 5 karena angka selalu 0 di render ini.

Challenge 2: Timer Countdown

Buat countdown timer yang:

  • Mulai dari 5
  • Setiap detik berkurang 1
  • Saat mencapai 0, tampilkan "WAKTU HABIS!"
  • Ada tombol "Mulai" untuk memulai countdown

Tantangannya: gunakan setTimeout (bukan setInterval) dan updater function.

💡 Hint

Karena setTimeout menangkap snapshot, kamu perlu pakai updater function (prev => prev - 1) agar selalu pakai nilai terbaru. Untuk membuat countdown berulang, panggil setTimeout di dalam handler yang dipicu oleh state change.

Atau, cara lebih simpel: pakai setInterval dengan updater function.

✅ Solusi
jsx
import { useState } from 'react';

function Countdown() {
  const [waktu, setWaktu] = useState(5);
  const [berjalan, setBerjalan] = useState(false);

  function handleMulai() {
    setBerjalan(true);
    
    // Pakai setInterval dengan updater function
    const intervalId = setInterval(() => {
      setWaktu(prev => {
        if (prev <= 1) {
          clearInterval(intervalId);
          setBerjalan(false);
          return 0;
        }
        return prev - 1;
      });
    }, 1000);
  }

  function handleReset() {
    setWaktu(5);
    setBerjalan(false);
  }

  return (
    <div style={{ textAlign: 'center', fontSize: '48px' }}>
      {waktu === 0 ? (
        <p style={{ color: 'red' }}>WAKTU HABIS!</p>
      ) : (
        <p>{waktu}</p>
      )}
      <button onClick={handleMulai} disabled={berjalan}>
        Mulai
      </button>
      <button onClick={handleReset}>
        Reset
      </button>
    </div>
  );
}

Perhatiin: kita pakai prev => prev - 1 (updater function) bukan waktu - 1. Kalau pakai waktu - 1, semua interval akan pakai snapshot waktu = 5, jadi hasilnya selalu 4. Dengan updater function, setiap interval pakai nilai terbaru.

Challenge 3: Chat dengan Delay Kirim

Buat komponen chat sederhana:

  • Input untuk ketik pesan
  • Dropdown untuk pilih penerima (Budi, Ani, Citra)
  • Tombol "Kirim" yang mengirim pesan dengan delay 3 detik
  • Setelah 3 detik, tampilkan konfirmasi: "Pesan [isi pesan] terkirim ke [penerima]"
  • Buktikan bahwa meskipun kamu ganti penerima SETELAH klik kirim, pesan tetap terkirim ke penerima yang dipilih SAAT klik
💡 Hint

Ini sebenarnya memanfaatkan snapshot behavior. Handler yang dipanggil saat klik "menangkap" state saat itu. Jadi meskipun state berubah setelahnya, handler tetap pakai nilai lama.

✅ Solusi
jsx
import { useState } from 'react';

function ChatDenganDelay() {
  const [penerima, setPenerima] = useState('Budi');
  const [pesan, setPesan] = useState('');
  const [log, setLog] = useState([]);

  function handleKirim() {
    if (!pesan.trim()) return;

    // Snapshot: penerima dan pesan saat ini "ditangkap" oleh closure
    const pesanSaatIni = pesan;
    const penerimaSaatIni = penerima;

    setLog(prev => [...prev, `⏳ Mengirim "${pesanSaatIni}" ke ${penerimaSaatIni}...`]);
    setPesan(''); // Kosongkan input

    setTimeout(() => {
      // 3 detik kemudian — masih pakai snapshot!
      setLog(prev => [...prev, `✅ Pesan "${pesanSaatIni}" terkirim ke ${penerimaSaatIni}`]);
    }, 3000);
  }

  return (
    <div>
      <h3>Chat App</h3>
      
      <div>
        <label>Penerima: </label>
        <select value={penerima} onChange={(e) => setPenerima(e.target.value)}>
          <option value="Budi">Budi</option>
          <option value="Ani">Ani</option>
          <option value="Citra">Citra</option>
        </select>
      </div>

      <div>
        <input
          value={pesan}
          onChange={(e) => setPesan(e.target.value)}
          placeholder="Ketik pesan..."
        />
        <button onClick={handleKirim}>Kirim (3s delay)</button>
      </div>

      <div style={{ marginTop: '20px', fontFamily: 'monospace' }}>
        <h4>Log:</h4>
        {log.map((entry, i) => (
          <p key={i}>{entry}</p>
        ))}
      </div>

      <p style={{ color: 'gray', fontSize: '12px' }}>
        Coba: kirim pesan ke Budi, lalu langsung ganti penerima ke Ani.
        Pesan tetap terkirim ke Budi!
      </p>
    </div>
  );
}

Ringkasan

  • State itu snapshot — nilainya tetap (fixed) dalam satu render
  • setState nggak mengubah variabel saat ini, dia menjadwalkan render baru
  • Setiap render punya "versi" state-nya sendiri
  • Event handler "menangkap" state dari render di mana dia dibuat
  • setTimeout/setInterval juga menangkap snapshot state
  • Kalau butuh nilai baru segera, simpan di variabel lokal
  • Untuk update berdasarkan nilai sebelumnya, pakai updater function (bab berikutnya)

Konsep snapshot ini fundamental banget. Kalau kamu paham ini, kamu bakal jarang kena bug "state nggak update" yang bikin frustasi. Di bab berikutnya, kita bakal belajar updater function — cara untuk "mengantri" update state yang bergantung pada nilai sebelumnya.

Sudah paham materi ini?

Tandai sebagai selesai untuk melacak progress-mu.