Bab 1: Referensi dengan Refs

6 menit baca

Apa Itu Ref?

Bayangin kamu punya papan tulis kecil di meja kerja. Kamu bisa nulis catatan di situ, hapus, ganti kapan aja. Tapi yang penting: tulisan di papan tulis itu nggak bikin bos kamu ngecek kerjaan kamu. Beda sama kalau kamu kirim email resmi (state), yang langsung bikin orang lain bereaksi.

Nah, useRef di React itu persis kayak papan tulis pribadi itu. Tempat nyimpen nilai yang bisa berubah, tapi perubahannya nggak bikin komponen render ulang.

jsx
import { useRef } from 'react';

function Komponen() {
  // Bikin ref, isinya awalnya 0
  const hitungan = useRef(0);
  
  // Akses nilainya pakai .current
  console.log(hitungan.current); // 0
}

Kenapa Ref Itu Penting?

Kadang kamu butuh nyimpen sesuatu yang nggak perlu ditampilin ke layar. Contoh:

  • ID dari setTimeout atau setInterval (buat di-cancel nanti)
  • Elemen DOM yang mau kamu manipulasi langsung
  • Nilai sebelumnya dari suatu state
  • Counter internal yang nggak perlu ditampilin

Kalau kamu pakai useState buat hal-hal ini, setiap kali nilainya berubah, komponen bakal render ulang. Itu pemborosan! Kayak kamu kirim email ke seluruh kantor cuma buat bilang "gue udah minum kopi ke-3 hari ini". Nggak perlu, kan?

useRef vs useState: Apa Bedanya?

Ini perbedaan paling fundamental yang harus kamu pahami:

AspekuseStateuseRef
Trigger render ulang?✅ Ya❌ Tidak
Cara akses nilaiLangsung (count)Lewat .current (ref.current)
Immutable?Ya (harus pakai setter)Tidak (langsung mutasi)
Cocok untukData yang ditampilin di UIData internal yang nggak perlu ditampilin
jsx
import { useState, useRef } from 'react';

function Perbandingan() {
  // State: kalau berubah, komponen render ulang
  const [nama, setNama] = useState('Budi');
  
  // Ref: kalau berubah, komponen TIDAK render ulang
  const jumlahKlik = useRef(0);
  
  function handleKlik() {
    // Ubah ref langsung (mutasi)
    jumlahKlik.current = jumlahKlik.current + 1;
    console.log(`Sudah diklik ${jumlahKlik.current} kali`);
    // Tapi di layar nggak ada yang berubah!
  }
  
  return (
    <div>
      <p>Nama: {nama}</p>
      {/* Ini NGGAK bakal update di layar meskipun nilainya berubah */}
      <p>Klik: {jumlahKlik.current}</p>
      <button onClick={handleKlik}>Klik Aku</button>
    </div>
  );
}

Coba jalanin kode di atas. Kamu bakal lihat di console jumlahnya naik, tapi di layar angkanya tetap 0. Kenapa? Karena ref nggak trigger render ulang!

Analogi: Ref itu Kayak Laci Meja

Bayangin komponen React itu kayak meja kerja di kantor:

  • State = papan pengumuman di dinding. Kalau kamu ganti isinya, semua orang langsung lihat (render ulang).
  • Ref = laci meja pribadi. Kamu bisa simpen apa aja di dalamnya, buka tutup sesuka hati, tapi nggak ada yang tahu isinya berubah kecuali kamu sendiri yang buka.

React "nggak peduli" sama isi ref. Dia cuma nyediain tempatnya. Terserah kamu mau simpen apa di situ.

Cara Kerja useRef di Balik Layar

Sebenernya, useRef itu cuma objek JavaScript biasa:

jsx
// Ini yang React lakuin di balik layar (kurang lebih)
function useRef(nilaiAwal) {
  return { current: nilaiAwal };
}

Yap, sesederhana itu! Bedanya sama objek biasa: React menjamin objek ref yang sama bakal dikasih ke komponen kamu di setiap render. Jadi nilainya persisten antar render.

jsx
function Timer() {
  // Objek ref ini SAMA di setiap render
  const intervalId = useRef(null);
  
  // Render ke-1: intervalId = { current: null }
  // Render ke-2: intervalId = { current: null } ← OBJEK YANG SAMA
  // Render ke-3: intervalId = { current: 123 }  ← masih objek yang sama, cuma .current berubah
}

Contoh Nyata 1: Stopwatch (Timer)

Ini contoh klasik penggunaan ref. Kita bikin stopwatch yang bisa start, stop, dan reset.

jsx
import { useState, useRef } from 'react';

function Stopwatch() {
  const [waktuBerjalan, setWaktuBerjalan] = useState(0);
  const [sedangJalan, setSedangJalan] = useState(false);
  
  // Simpen ID interval di ref (nggak perlu ditampilin di UI)
  const intervalRef = useRef(null);
  // Simpen waktu mulai di ref
  const waktuMulaiRef = useRef(0);
  
  function handleMulai() {
    setSedangJalan(true);
    waktuMulaiRef.current = Date.now() - waktuBerjalan;
    
    // Simpen interval ID di ref biar bisa di-clear nanti
    intervalRef.current = setInterval(() => {
      setWaktuBerjalan(Date.now() - waktuMulaiRef.current);
    }, 10);
  }
  
  function handleBerhenti() {
    setSedangJalan(false);
    // Pakai ref buat clear interval
    clearInterval(intervalRef.current);
  }
  
  function handleReset() {
    setSedangJalan(false);
    clearInterval(intervalRef.current);
    setWaktuBerjalan(0);
  }
  
  // Format waktu jadi menit:detik:milidetik
  const menit = Math.floor(waktuBerjalan / 60000);
  const detik = Math.floor((waktuBerjalan % 60000) / 1000);
  const milidetik = Math.floor((waktuBerjalan % 1000) / 10);
  
  return (
    <div>
      <h1>
        {String(menit).padStart(2, '0')}:
        {String(detik).padStart(2, '0')}.
        {String(milidetik).padStart(2, '0')}
      </h1>
      
      {sedangJalan ? (
        <button onClick={handleBerhenti}>Berhenti</button>
      ) : (
        <button onClick={handleMulai}>Mulai</button>
      )}
      <button onClick={handleReset}>Reset</button>
    </div>
  );
}

Coba sendiri: Edit kode di bawah dan lihat hasilnya langsung!

Kenapa intervalRef pakai ref, bukan state?

Karena ID interval itu cuma "tiket" buat nge-cancel timer nanti. Kita nggak perlu nampilin angka ID itu di layar. Kalau pakai state, setiap kali interval dibuat (dan ID-nya berubah), komponen bakal render ulang tanpa alasan.

Contoh Nyata 2: Menyimpan Nilai Sebelumnya

Kadang kamu butuh tahu "nilai sebelumnya" dari suatu state. Ref cocok banget buat ini:

jsx
import { useState, useRef, useEffect } from 'react';

function HitungPerubahan() {
  const [angka, setAngka] = useState(0);
  const angkaSebelumnya = useRef(0);
  
  // Simpan nilai sebelumnya setiap kali angka berubah
  useEffect(() => {
    angkaSebelumnya.current = angka;
  });
  
  return (
    <div>
      <p>Sekarang: {angka}</p>
      <p>Sebelumnya: {angkaSebelumnya.current}</p>
      <button onClick={() => setAngka(angka + 1)}>Tambah</button>
      <button onClick={() => setAngka(angka - 1)}>Kurang</button>
    </div>
  );
}

Gimana cara kerjanya?

  1. Render pertama: angka = 0, angkaSebelumnya.current = 0
  2. Klik "Tambah": angka jadi 1, komponen render ulang
  3. Saat render: angkaSebelumnya.current masih 0 (belum diupdate)
  4. Setelah render: Effect jalan, update angkaSebelumnya.current = 1
  5. Klik "Tambah" lagi: angka jadi 2, render ulang
  6. Saat render: angkaSebelumnya.current = 1 (nilai dari langkah 4)

Jadi ref selalu "ketinggalan satu langkah" dari state. Itulah triknya!

Contoh Nyata 3: Menghitung Jumlah Render

jsx
import { useRef, useEffect } from 'react';

function PenghitungRender() {
  const jumlahRender = useRef(0);
  
  useEffect(() => {
    jumlahRender.current = jumlahRender.current + 1;
  });
  
  return (
    <div>
      <p>Komponen ini sudah render {jumlahRender.current} kali</p>
    </div>
  );
}

Kenapa nggak pakai state? Karena kalau pakai useState, setiap kali kita update counter, dia trigger render lagi, yang trigger update counter lagi... infinite loop! 💀

Kapan Pakai Ref vs State?

Pakai panduan ini:

Pakai useState kalau:

  • Nilainya ditampilin di UI
  • Perubahan nilainya harus bikin tampilan update
  • Contoh: nama user, daftar todo, status loading

Pakai useRef kalau:

  • Nilainya NGGAK ditampilin di UI (atau nggak perlu update real-time)
  • Kamu butuh nilai yang persisten antar render tapi nggak trigger render
  • Kamu mau akses elemen DOM langsung
  • Contoh: timer ID, elemen DOM, nilai sebelumnya, counter internal
jsx
function ContohKapanPakaiApa() {
  // ✅ State: ditampilin di UI, harus trigger render
  const [pesan, setPesan] = useState('');
  
  // ✅ Ref: ID timer, nggak perlu ditampilin
  const timerRef = useRef(null);
  
  // ✅ Ref: input DOM element, buat fokus
  const inputRef = useRef(null);
  
  // ❌ JANGAN: pakai ref buat data yang ditampilin di UI
  // const hitungan = useRef(0); // Kalau mau tampilin, pakai state!
  
  // ❌ JANGAN: pakai state buat data internal
  // const [timerId, setTimerId] = useState(null); // Pemborosan render!
}

Aturan Penting: Jangan Baca/Tulis Ref Saat Render

Ini aturan yang sering dilanggar pemula:

jsx
function Salah() {
  const hitungan = useRef(0);
  
  // ❌ JANGAN! Baca ref di body render
  // Hasilnya nggak predictable
  hitungan.current = hitungan.current + 1;
  
  return <p>{hitungan.current}</p>;
}

function Benar() {
  const hitungan = useRef(0);
  
  // ✅ Baca/tulis ref di event handler atau Effect
  function handleKlik() {
    hitungan.current = hitungan.current + 1;
    alert(`Diklik ${hitungan.current} kali`);
  }
  
  return <button onClick={handleKlik}>Klik</button>;
}

Kenapa? Karena React bisa render komponen kamu kapan aja, bahkan berkali-kali sebelum ditampilin ke layar (Strict Mode). Kalau kamu mutasi ref di body render, hasilnya bisa nggak konsisten.

Pengecualian: Boleh baca/tulis ref saat render kalau cuma buat inisialisasi:

jsx
function LazyInit() {
  const mahal = useRef(null);
  
  // ✅ Ini OK: inisialisasi sekali
  if (mahal.current === null) {
    mahal.current = hitungSesuatuYangMahal();
  }
  
  return <p>{mahal.current}</p>;
}

Ref Sebagai "Escape Hatch"

Nama bab ini "Escape Hatches" (pintu darurat) bukan tanpa alasan. Ref itu kayak pintu darurat di pesawat. Ada, boleh dipakai, tapi jangan jadi jalan utama.

Kalau kamu nemu diri kamu pakai ref di mana-mana, kemungkinan besar ada yang salah sama arsitektur komponen kamu. Tanya diri sendiri:

  • "Apakah nilai ini harusnya ditampilin di UI?" → Pakai state
  • "Apakah ini bisa dihitung dari state/props yang ada?" → Hitung langsung, nggak perlu simpen
  • "Apakah ini beneran cuma data internal?" → OK, pakai ref

⚠️ Jebakan

Jebakan 1: Mengharapkan UI Update Saat Ref Berubah

jsx
function JebakanSatu() {
  const hitungan = useRef(0);
  
  return (
    <div>
      {/* Ini NGGAK bakal update! */}
      <p>Hitungan: {hitungan.current}</p>
      <button onClick={() => {
        hitungan.current += 1;
        // UI tetap nunjukin angka lama!
        console.log(hitungan.current); // Ini bener, tapi layar nggak update
      }}>
        Tambah
      </button>
    </div>
  );
}

Solusi: Kalau mau UI update, pakai useState.

Jebakan 2: Lupa .current

jsx
function JebakanDua() {
  const inputRef = useRef(null);
  
  function fokus() {
    // ❌ Salah! inputRef itu objek { current: ... }
    // inputRef.focus();
    
    // ✅ Benar! Akses lewat .current
    inputRef.current.focus();
  }
  
  return <input ref={inputRef} />;
}

Jebakan 3: Pakai Ref Buat Semua Hal

jsx
// ❌ Anti-pattern: pakai ref buat data yang harusnya state
function FormSalah() {
  const namaRef = useRef('');
  const emailRef = useRef('');
  
  // Masalah: form nggak re-render saat user ngetik
  // Jadi value di input nggak sync sama yang ditampilin
  
  return (
    <div>
      <input onChange={(e) => namaRef.current = e.target.value} />
      <p>Nama: {namaRef.current}</p> {/* Nggak pernah update! */}
    </div>
  );
}

// ✅ Benar: pakai state buat form data
function FormBenar() {
  const [nama, setNama] = useState('');
  
  return (
    <div>
      <input value={nama} onChange={(e) => setNama(e.target.value)} />
      <p>Nama: {nama}</p> {/* Update real-time */}
    </div>
  );
}

Jebakan 4: Infinite Loop dengan State sebagai Timer ID

jsx
// ❌ Ini bisa bikin masalah
function TimerSalah() {
  const [intervalId, setIntervalId] = useState(null);
  
  function mulai() {
    // setIntervalId trigger render ulang
    // Render ulang bisa bikin logic aneh kalau nggak hati-hati
    const id = setInterval(() => {
      console.log('tick');
    }, 1000);
    setIntervalId(id); // Render ulang! Padahal nggak perlu
  }
  
  function berhenti() {
    clearInterval(intervalId);
  }
}

// ✅ Pakai ref buat timer ID
function TimerBenar() {
  const intervalRef = useRef(null);
  
  function mulai() {
    intervalRef.current = setInterval(() => {
      console.log('tick');
    }, 1000);
    // Nggak ada render ulang yang nggak perlu!
  }
  
  function berhenti() {
    clearInterval(intervalRef.current);
  }
}

Ringkasan

  1. useRef bikin objek { current: nilaiAwal } yang persisten antar render
  2. Mengubah ref.current TIDAK trigger render ulang
  3. Cocok buat: timer ID, elemen DOM, nilai sebelumnya, data internal
  4. JANGAN pakai ref buat data yang ditampilin di UI
  5. JANGAN baca/tulis ref di body render (kecuali inisialisasi)
  6. Ref itu "escape hatch", bukan pengganti state

🏋️ Challenge

Challenge 1: Chat dengan Pesan Terbaru Selalu Terlihat

Bikin komponen chat yang otomatis scroll ke bawah setiap ada pesan baru.

Hint: Pakai ref buat akses elemen container, lalu panggil scrollIntoView().

Lihat Solusi
jsx
import { useState, useRef, useEffect } from 'react';

function Chat() {
  const [pesan, setPesan] = useState([
    'Halo!',
    'Apa kabar?',
    'Lagi ngapain?'
  ]);
  const [inputBaru, setInputBaru] = useState('');
  const pesanAkhirRef = useRef(null);
  
  // Scroll ke bawah setiap ada pesan baru
  useEffect(() => {
    pesanAkhirRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [pesan]);
  
  function kirimPesan() {
    if (inputBaru.trim() === '') return;
    setPesan([...pesan, inputBaru]);
    setInputBaru('');
  }
  
  return (
    <div>
      <div style={{ height: '200px', overflow: 'auto', border: '1px solid gray' }}>
        {pesan.map((p, i) => (
          <p key={i}>{p}</p>
        ))}
        {/* Elemen invisible di paling bawah buat target scroll */}
        <div ref={pesanAkhirRef} />
      </div>
      
      <input
        value={inputBaru}
        onChange={(e) => setInputBaru(e.target.value)}
        onKeyDown={(e) => e.key === 'Enter' && kirimPesan()}
        placeholder="Ketik pesan..."
      />
      <button onClick={kirimPesan}>Kirim</button>
    </div>
  );
}

Bikin input pencarian yang baru nge-fetch setelah user berhenti ngetik selama 500ms. Pakai ref buat simpen timer ID.

Hint: Setiap kali user ngetik, clear timer sebelumnya dan bikin timer baru.

Lihat Solusi
jsx
import { useState, useRef } from 'react';

function DebouncedSearch() {
  const [query, setQuery] = useState('');
  const [hasil, setHasil] = useState('');
  const timerRef = useRef(null);
  
  function handleChange(e) {
    const nilai = e.target.value;
    setQuery(nilai);
    
    // Clear timer sebelumnya
    if (timerRef.current) {
      clearTimeout(timerRef.current);
    }
    
    // Bikin timer baru, jalan setelah 500ms
    timerRef.current = setTimeout(() => {
      // Simulasi fetch
      setHasil(`Mencari: "${nilai}"... (hasil dari server)`);
    }, 500);
  }
  
  return (
    <div>
      <input
        value={query}
        onChange={handleChange}
        placeholder="Cari sesuatu..."
      />
      <p>{hasil}</p>
    </div>
  );
}

Penjelasan: Tanpa ref, kita nggak bisa nyimpen timer ID antar ketikan. Setiap ketikan bikin timer baru, tapi kita clear yang lama dulu. Jadi fetch cuma jalan kalau user udah berhenti ngetik 500ms.

Challenge 3: Komponen Toggle dengan Hitung Berapa Kali Di-toggle

Bikin tombol toggle (on/off) yang menampilkan status saat ini DAN menghitung total berapa kali di-toggle (tanpa re-render tambahan untuk counter).

Hint: Status on/off pakai state (karena ditampilin), tapi counter pakai ref (karena cuma ditampilin di console/alert).

Lihat Solusi
jsx
import { useState, useRef } from 'react';

function Toggle() {
  const [aktif, setAktif] = useState(false);
  const jumlahToggle = useRef(0);
  
  function handleToggle() {
    setAktif(!aktif);
    jumlahToggle.current += 1;
  }
  
  function lihatStatistik() {
    alert(`Toggle sudah ditekan ${jumlahToggle.current} kali`);
  }
  
  return (
    <div>
      <button onClick={handleToggle}>
        {aktif ? '🟢 ON' : '🔴 OFF'}
      </button>
      <button onClick={lihatStatistik}>
        Lihat Statistik
      </button>
    </div>
  );
}

Penjelasan:

  • aktif pakai state karena UI harus berubah (tombol nunjukin ON/OFF)
  • jumlahToggle pakai ref karena kita nggak perlu nampilin angkanya di UI secara real-time. Cuma ditampilin kalau user klik "Lihat Statistik"
  • Ini menghemat 1 render setiap kali toggle ditekan!

Sudah paham materi ini?

Tandai sebagai selesai untuk melacak progress-mu.