Bab 2: Manipulasi DOM dengan Refs
⏱ 5 menit bacaKenapa Perlu Akses DOM Langsung?
React biasanya ngurus DOM buat kamu. Kamu bilang "tampilin <input>", React yang bikin elemen input di halaman. Kamu bilang "ubah teks jadi 'Halo'", React yang update DOM-nya.
Tapi kadang, ada hal yang React nggak bisa lakuin lewat JSX:
- Fokus ke input tertentu
- Scroll ke elemen tertentu
- Ukur tinggi/lebar elemen
- Putar/pause video
- Integrasi sama library non-React (peta, chart, dll)
Buat hal-hal ini, kamu butuh akses langsung ke elemen DOM. Dan caranya? Pakai ref.
Analogi: Remote Control TV
Bayangin React itu kayak asisten yang ngatur ruang tamu kamu. Kamu bilang "taruh TV di situ, sofa di sini", dan dia yang kerjain. Tapi kalau kamu mau ganti channel TV, kamu butuh remote control (ref) buat langsung kontrol TV-nya sendiri, bukan nyuruh asisten pencet tombol satu-satu.
Ref ke DOM itu remote control kamu ke elemen HTML yang udah React taruh di halaman.
Mendapatkan Elemen DOM dengan Ref
Langkah-langkahnya:
- Import
useRef - Bikin ref:
const elemenRef = useRef(null) - Pasang ref ke elemen JSX:
<div ref={elemenRef}> - Akses DOM node lewat
elemenRef.current
import { useRef } from 'react';
function FormLogin() {
// Langkah 1 & 2: Bikin ref
const inputEmailRef = useRef(null);
function handleKlikFokus() {
// Langkah 4: Akses DOM node
inputEmailRef.current.focus();
}
return (
<div>
{/* Langkah 3: Pasang ref ke elemen */}
<input ref={inputEmailRef} type="email" placeholder="Email kamu" />
<button onClick={handleKlikFokus}>Fokus ke Email</button>
</div>
);
}Apa yang terjadi di balik layar:
- Saat komponen pertama kali render, React bikin elemen
<input>di DOM - React simpen referensi ke elemen itu di
inputEmailRef.current - Sekarang
inputEmailRef.currentitu elemen DOM asli, sama persis kayak yang kamu dapet daridocument.getElementById()
Contoh: Fokus Input Otomatis
Ini pattern yang super sering dipakai. Misalnya halaman login, kamu mau cursor langsung ada di input email begitu halaman kebuka:
import { useRef, useEffect } from 'react';
function HalamanLogin() {
const emailRef = useRef(null);
// Fokus otomatis saat komponen muncul
useEffect(() => {
emailRef.current.focus();
}, []); // [] = cuma jalan sekali saat mount
return (
<form>
<h2>Login</h2>
<div>
<label>Email:</label>
<input ref={emailRef} type="email" />
</div>
<div>
<label>Password:</label>
<input type="password" />
</div>
<button type="submit">Masuk</button>
</form>
);
}Contoh: Scroll ke Elemen Tertentu
Bayangin kamu bikin daftar kontak yang panjang, dan ada tombol buat langsung scroll ke kontak tertentu:
import { useRef } from 'react';
function DaftarKontak() {
const kontakARef = useRef(null);
const kontakBRef = useRef(null);
const kontakCRef = useRef(null);
function scrollKe(ref) {
ref.current.scrollIntoView({
behavior: 'smooth', // Animasi halus
block: 'center' // Taruh di tengah layar
});
}
return (
<div>
<nav>
<button onClick={() => scrollKe(kontakARef)}>Ke Andi</button>
<button onClick={() => scrollKe(kontakBRef)}>Ke Budi</button>
<button onClick={() => scrollKe(kontakCRef)}>Ke Citra</button>
</nav>
<div style={{ height: '300px', overflow: 'auto' }}>
<div style={{ height: '400px' }}>Kontak lain...</div>
<div ref={kontakARef} style={{ padding: '20px', background: '#e3f2fd' }}>
<h3>Andi</h3>
<p>081234567890</p>
</div>
<div style={{ height: '400px' }}>Kontak lain...</div>
<div ref={kontakBRef} style={{ padding: '20px', background: '#e8f5e9' }}>
<h3>Budi</h3>
<p>082345678901</p>
</div>
<div style={{ height: '400px' }}>Kontak lain...</div>
<div ref={kontakCRef} style={{ padding: '20px', background: '#fff3e0' }}>
<h3>Citra</h3>
<p>083456789012</p>
</div>
<div style={{ height: '400px' }}>Kontak lain...</div>
</div>
</div>
);
}Mengukur Elemen: getBoundingClientRect
Kadang kamu perlu tahu ukuran atau posisi elemen. Misalnya buat bikin tooltip yang muncul di posisi yang tepat:
import { useRef, useState } from 'react';
function PengukurElemen() {
const kotakRef = useRef(null);
const [ukuran, setUkuran] = useState(null);
function ukurKotak() {
// getBoundingClientRect() kasih info lengkap tentang posisi & ukuran
const rect = kotakRef.current.getBoundingClientRect();
setUkuran({
lebar: Math.round(rect.width),
tinggi: Math.round(rect.height),
atas: Math.round(rect.top),
kiri: Math.round(rect.left)
});
}
return (
<div>
<div
ref={kotakRef}
style={{
width: '200px',
height: '100px',
background: 'salmon',
padding: '20px'
}}
>
Kotak yang diukur
</div>
<button onClick={ukurKotak}>Ukur Kotak</button>
{ukuran && (
<p>
Lebar: {ukuran.lebar}px, Tinggi: {ukuran.tinggi}px,
Posisi: ({ukuran.kiri}, {ukuran.atas})
</p>
)}
</div>
);
}Ref Callback: Cara Lebih Fleksibel
Selain ngasih objek ref, kamu bisa ngasih fungsi ke prop ref. Fungsi ini dipanggil React dengan elemen DOM sebagai argumen:
import { useState } from 'react';
function DaftarDinamis() {
const [items, setItems] = useState(['Apel', 'Jeruk', 'Mangga']);
const elemenMap = new Map(); // Simpen semua ref di Map
function scrollKeItem(id) {
const node = elemenMap.get(id);
if (node) {
node.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
return (
<div>
<button onClick={() => scrollKeItem(0)}>Ke Apel</button>
<button onClick={() => scrollKeItem(2)}>Ke Mangga</button>
<ul style={{ height: '100px', overflow: 'auto' }}>
{items.map((item, index) => (
<li
key={index}
ref={(node) => {
// Ref callback: dipanggil dengan DOM node
if (node) {
elemenMap.set(index, node);
} else {
// null berarti elemen di-unmount
elemenMap.delete(index);
}
}}
style={{ padding: '40px' }}
>
{item}
</li>
))}
</ul>
</div>
);
}Kapan pakai ref callback?
- Kalau jumlah elemen yang butuh ref itu dinamis (list yang bisa bertambah/berkurang)
- Kalau kamu nggak tahu berapa banyak ref yang dibutuhkan saat compile time
- Kalau kamu butuh logic khusus saat elemen muncul/hilang dari DOM
Forwarding Refs ke Komponen Anak (forwardRef)
Ini bagian yang sering bikin bingung. Secara default, komponen React nggak expose DOM node-nya ke parent. Jadi ini nggak bakal jalan:
// ❌ Ini NGGAK JALAN!
function InputKustom({ placeholder }) {
return <input placeholder={placeholder} />;
}
function Parent() {
const inputRef = useRef(null);
return (
// ref nggak bakal nyampe ke <input> di dalam InputKustom
<InputKustom ref={inputRef} placeholder="Ketik..." />
);
}Kenapa React nggak otomatis forward ref? Karena enkapsulasi. Komponen anak punya hak buat nyembunyiin detail implementasinya. Mungkin besok InputKustom berubah jadi pake <textarea> atau wrapper <div>. Kalau parent langsung akses DOM-nya, perubahan itu bisa bikin parent rusak.
Solusi: forwardRef
import { useRef, forwardRef } from 'react';
// Bungkus komponen dengan forwardRef
const InputKustom = forwardRef(function InputKustom({ placeholder, label }, ref) {
return (
<div>
<label>{label}</label>
{/* Forward ref ke elemen DOM yang diinginkan */}
<input ref={ref} placeholder={placeholder} />
</div>
);
});
function FormPendaftaran() {
const namaRef = useRef(null);
function fokusNama() {
// Sekarang ini jalan! ref di-forward ke <input> di dalam InputKustom
namaRef.current.focus();
}
return (
<div>
<InputKustom ref={namaRef} label="Nama:" placeholder="Nama lengkap" />
<button onClick={fokusNama}>Fokus ke Nama</button>
</div>
);
}Cara kerja forwardRef:
- Parent bikin ref dan pasang ke
<InputKustom ref={namaRef}> - React lihat
InputKustomdibungkusforwardRef, jadi dia terusin ref sebagai parameter kedua InputKustomterima ref dan pasang ke<input ref={ref}>- Sekarang
namaRef.currentdi parent = elemen<input>di dalamInputKustom
Membatasi Akses dengan useImperativeHandle
Kadang kamu mau forward ref tapi nggak mau kasih akses penuh ke DOM node. Misalnya kamu cuma mau parent bisa .focus(), tapi nggak bisa .remove() atau ubah style:
import { useRef, forwardRef, useImperativeHandle } from 'react';
const InputTerbatas = forwardRef(function InputTerbatas({ placeholder }, ref) {
const inputAsliRef = useRef(null);
// Batasi apa yang bisa diakses parent lewat ref
useImperativeHandle(ref, () => ({
// Parent cuma bisa panggil focus() dan clear()
focus() {
inputAsliRef.current.focus();
},
clear() {
inputAsliRef.current.value = '';
}
// Parent NGGAK bisa akses .style, .remove(), dll
}));
return <input ref={inputAsliRef} placeholder={placeholder} />;
});
function App() {
const inputRef = useRef(null);
return (
<div>
<InputTerbatas ref={inputRef} placeholder="Ketik..." />
<button onClick={() => inputRef.current.focus()}>Fokus</button>
<button onClick={() => inputRef.current.clear()}>Hapus</button>
{/* inputRef.current.remove() ← NGGAK BISA! Nggak di-expose */}
</div>
);
}Ini kayak kasih remote TV yang cuma ada tombol channel dan volume, tanpa tombol power off. Aman!
Contoh Lengkap: Video Player
import { useRef, useState } from 'react';
function VideoPlayer() {
const videoRef = useRef(null);
const [sedangPutar, setSedangPutar] = useState(false);
function handlePutarPause() {
if (sedangPutar) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setSedangPutar(!sedangPutar);
}
function handleMundur10Detik() {
videoRef.current.currentTime -= 10;
}
function handleMaju10Detik() {
videoRef.current.currentTime += 10;
}
return (
<div>
<video
ref={videoRef}
width="400"
src="https://example.com/video.mp4"
onEnded={() => setSedangPutar(false)}
/>
<div>
<button onClick={handleMundur10Detik}>⏪ -10s</button>
<button onClick={handlePutarPause}>
{sedangPutar ? '⏸ Pause' : '▶️ Play'}
</button>
<button onClick={handleMaju10Detik}>⏩ +10s</button>
</div>
</div>
);
}Kenapa pakai ref buat video? Karena React nggak punya prop playing={true/false} buat <video>. Satu-satunya cara putar/pause video adalah panggil method .play() dan .pause() langsung di elemen DOM.
Contoh: Carousel/Slider Gambar
import { useRef, useState } from 'react';
function Carousel({ gambar }) {
const containerRef = useRef(null);
const [indexAktif, setIndexAktif] = useState(0);
function scrollKeGambar(index) {
setIndexAktif(index);
const container = containerRef.current;
const lebarGambar = container.offsetWidth;
container.scrollTo({
left: index * lebarGambar,
behavior: 'smooth'
});
}
function sebelumnya() {
const indexBaru = indexAktif > 0 ? indexAktif - 1 : gambar.length - 1;
scrollKeGambar(indexBaru);
}
function selanjutnya() {
const indexBaru = indexAktif < gambar.length - 1 ? indexAktif + 1 : 0;
scrollKeGambar(indexBaru);
}
return (
<div>
<div
ref={containerRef}
style={{
display: 'flex',
overflow: 'hidden',
width: '300px'
}}
>
{gambar.map((src, i) => (
<img
key={i}
src={src}
alt={`Gambar ${i + 1}`}
style={{ width: '300px', flexShrink: 0 }}
/>
))}
</div>
<button onClick={sebelumnya}>← Sebelumnya</button>
<span> {indexAktif + 1} / {gambar.length} </span>
<button onClick={selanjutnya}>Selanjutnya →</button>
</div>
);
}Kapan Manipulasi DOM Diperlukan vs Dihindari
✅ Boleh (Non-destructive)
- Focus/blur input
- Scroll ke posisi tertentu
- Ukur elemen (getBoundingClientRect)
- Play/pause media
- Integrasi library pihak ketiga (chart, map)
❌ Hindari (Destructive, bisa konflik sama React)
- Menambah/menghapus elemen DOM yang dikelola React
- Mengubah innerHTML elemen yang punya children dari React
- Mengubah atribut yang juga dikontrol React (className, style, dll)
// ❌ BAHAYA! Jangan hapus elemen yang React kelola
function Bahaya() {
const divRef = useRef(null);
function hapusAnak() {
// INI BISA CRASH! React masih "ingat" ada children di situ
divRef.current.removeChild(divRef.current.firstChild);
}
return (
<div ref={divRef}>
<p>React pikir paragraf ini masih ada</p>
<button onClick={hapusAnak}>Hapus (JANGAN!)</button>
</div>
);
}Aturan emas: Jangan pernah modifikasi DOM yang juga dikelola React. Kalau React render <p>Halo</p>, jangan coba hapus atau ubah <p> itu lewat ref. Biarkan React yang ngurus.
Pengecualian aman: Kalau elemen itu "kosong" dari sisi React (nggak punya children React), kamu boleh manipulasi isinya:
// ✅ Aman: div ini nggak punya children React
function PetaGoogle() {
const mapContainerRef = useRef(null);
useEffect(() => {
// Aman karena React nggak naruh apa-apa di dalam div ini
const map = new google.maps.Map(mapContainerRef.current, {
center: { lat: -6.2, lng: 106.8 },
zoom: 12
});
}, []);
return <div ref={mapContainerRef} style={{ width: '100%', height: '400px' }} />;
}Timing: Kapan Ref Terisi?
Penting dipahami: ref baru terisi setelah React selesai render dan commit ke DOM.
function TimingRef() {
const divRef = useRef(null);
// ❌ Di sini ref masih null (belum render)
console.log(divRef.current); // null saat render pertama
useEffect(() => {
// ✅ Di sini ref sudah terisi (setelah render)
console.log(divRef.current); // <div>...</div>
}, []);
function handleKlik() {
// ✅ Di sini juga sudah terisi (event handler jalan setelah render)
console.log(divRef.current); // <div>...</div>
}
return <div ref={divRef}>Halo</div>;
}Timeline:
- React panggil fungsi komponen kamu (render) → ref masih null
- React update DOM berdasarkan JSX yang kamu return
- React isi
ref.currentdengan DOM node - React jalanin Effect dan event handler → ref sudah terisi
⚠️ Jebakan
Jebakan 1: Akses Ref Sebelum Mount
function JebakanMount() {
const inputRef = useRef(null);
// ❌ CRASH! ref belum terisi saat render
// inputRef.current.focus(); // TypeError: Cannot read property 'focus' of null
// ✅ Pakai useEffect atau event handler
useEffect(() => {
inputRef.current.focus(); // Aman, sudah mount
}, []);
return <input ref={inputRef} />;
}Jebakan 2: Ref di Komponen Tanpa forwardRef
// ❌ Warning! Komponen fungsi nggak bisa terima ref langsung
function InputBiasa({ placeholder }) {
return <input placeholder={placeholder} />;
}
function Parent() {
const ref = useRef(null);
// React bakal kasih warning di console
return <InputBiasa ref={ref} placeholder="Test" />;
}
// ✅ Solusi: pakai forwardRef
const InputDenganRef = forwardRef(function InputDenganRef({ placeholder }, ref) {
return <input ref={ref} placeholder={placeholder} />;
});Jebakan 3: Manipulasi DOM yang Konflik dengan React
function KonflikDOM() {
const [tampil, setTampil] = useState(true);
const containerRef = useRef(null);
function hapusLewatDOM() {
// ❌ BAHAYA! React masih "ingat" ada <p> di situ
containerRef.current.innerHTML = '';
// Nanti kalau setTampil(false) dipanggil, React bingung
// karena elemen yang mau di-unmount udah nggak ada
}
return (
<div ref={containerRef}>
{tampil && <p>Paragraf dari React</p>}
<button onClick={hapusLewatDOM}>Hapus (JANGAN!)</button>
<button onClick={() => setTampil(false)}>Hapus (BENAR)</button>
</div>
);
}Jebakan 4: Terlalu Banyak Ref
// ❌ Anti-pattern: ref buat setiap hal kecil
function TerlaluBanyakRef() {
const div1Ref = useRef(null);
const div2Ref = useRef(null);
const div3Ref = useRef(null);
const div4Ref = useRef(null);
// ... 20 ref lainnya
// Kalau butuh banyak ref, pertimbangkan ref callback + Map
}
// ✅ Lebih baik: pakai Map atau array
function LebihBaik() {
const elemenRefs = useRef(new Map());
return (
<div>
{items.map(item => (
<div
key={item.id}
ref={(node) => {
if (node) elemenRefs.current.set(item.id, node);
else elemenRefs.current.delete(item.id);
}}
>
{item.nama}
</div>
))}
</div>
);
}Ringkasan
- Pasang ref ke elemen JSX dengan
<div ref={myRef}>buat dapetin DOM node ref.currentberisi elemen DOM asli setelah render- Pakai buat: focus, scroll, ukur, play/pause, integrasi library
- Jangan manipulasi DOM yang juga dikelola React (tambah/hapus children)
- Pakai
forwardRefkalau mau komponen anak expose DOM-nya ke parent - Pakai
useImperativeHandlebuat batasi apa yang bisa diakses parent - Ref baru terisi setelah render, jadi akses di Effect atau event handler
🏋️ Challenge
Challenge 1: Form Multi-Step dengan Auto-Focus
Bikin form 3 langkah. Setiap kali user pindah ke langkah berikutnya, input pertama di langkah itu otomatis ter-focus.
Hint: Setiap langkah punya ref sendiri. Pakai useEffect yang depend pada langkah aktif.
Lihat Solusi
import { useState, useRef, useEffect } from 'react';
function FormMultiStep() {
const [langkah, setLangkah] = useState(1);
const namaRef = useRef(null);
const emailRef = useRef(null);
const alamatRef = useRef(null);
// Auto-focus setiap ganti langkah
useEffect(() => {
if (langkah === 1) namaRef.current?.focus();
if (langkah === 2) emailRef.current?.focus();
if (langkah === 3) alamatRef.current?.focus();
}, [langkah]);
return (
<div>
<h2>Langkah {langkah} dari 3</h2>
{langkah === 1 && (
<div>
<label>Nama Lengkap:</label>
<input ref={namaRef} placeholder="Nama kamu" />
</div>
)}
{langkah === 2 && (
<div>
<label>Email:</label>
<input ref={emailRef} type="email" placeholder="email@contoh.com" />
</div>
)}
{langkah === 3 && (
<div>
<label>Alamat:</label>
<input ref={alamatRef} placeholder="Jl. Contoh No. 123" />
</div>
)}
<div style={{ marginTop: '10px' }}>
{langkah > 1 && (
<button onClick={() => setLangkah(langkah - 1)}>← Kembali</button>
)}
{langkah < 3 && (
<button onClick={() => setLangkah(langkah + 1)}>Lanjut →</button>
)}
{langkah === 3 && (
<button onClick={() => alert('Selesai!')}>Kirim</button>
)}
</div>
</div>
);
}Challenge 2: Tooltip yang Mengikuti Elemen
Bikin tooltip yang muncul tepat di bawah elemen yang di-hover, menggunakan getBoundingClientRect().
Hint: Pakai ref callback atau ref biasa + onMouseEnter buat ukur posisi elemen.
Lihat Solusi
import { useState, useRef } from 'react';
function TooltipDemo() {
const [tooltip, setTooltip] = useState({ tampil: false, x: 0, y: 0, teks: '' });
const tombol1Ref = useRef(null);
const tombol2Ref = useRef(null);
function tampilkanTooltip(ref, teks) {
const rect = ref.current.getBoundingClientRect();
setTooltip({
tampil: true,
x: rect.left + rect.width / 2,
y: rect.bottom + 8, // 8px di bawah elemen
teks: teks
});
}
function sembunyikanTooltip() {
setTooltip({ ...tooltip, tampil: false });
}
return (
<div style={{ padding: '100px' }}>
<button
ref={tombol1Ref}
onMouseEnter={() => tampilkanTooltip(tombol1Ref, 'Ini tombol simpan')}
onMouseLeave={sembunyikanTooltip}
>
💾 Simpan
</button>
<button
ref={tombol2Ref}
onMouseEnter={() => tampilkanTooltip(tombol2Ref, 'Ini tombol hapus')}
onMouseLeave={sembunyikanTooltip}
style={{ marginLeft: '20px' }}
>
🗑️ Hapus
</button>
{tooltip.tampil && (
<div
style={{
position: 'fixed',
left: tooltip.x,
top: tooltip.y,
transform: 'translateX(-50%)',
background: '#333',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px'
}}
>
{tooltip.teks}
</div>
)}
</div>
);
}Challenge 3: Komponen Input dengan Tombol Clear dan Character Count
Bikin komponen input kustom yang punya tombol "X" buat clear, dan nunjukin jumlah karakter. Parent harus bisa fokus ke input lewat ref.
Hint: Pakai forwardRef + useImperativeHandle buat expose method focus() dan clear().
Lihat Solusi
import { useState, useRef, forwardRef, useImperativeHandle } from 'react';
const InputFancy = forwardRef(function InputFancy({ placeholder, maxLength = 100 }, ref) {
const [nilai, setNilai] = useState('');
const inputRef = useRef(null);
// Expose method terbatas ke parent
useImperativeHandle(ref, () => ({
focus() {
inputRef.current.focus();
},
clear() {
setNilai('');
inputRef.current.focus();
},
getValue() {
return nilai;
}
}));
function handleClear() {
setNilai('');
inputRef.current.focus();
}
return (
<div style={{ position: 'relative', display: 'inline-block' }}>
<input
ref={inputRef}
value={nilai}
onChange={(e) => setNilai(e.target.value.slice(0, maxLength))}
placeholder={placeholder}
style={{ paddingRight: '30px' }}
/>
{nilai && (
<button
onClick={handleClear}
style={{
position: 'absolute',
right: '5px',
top: '50%',
transform: 'translateY(-50%)',
border: 'none',
background: 'none',
cursor: 'pointer'
}}
>
✕
</button>
)}
<div style={{ fontSize: '12px', color: nilai.length >= maxLength ? 'red' : 'gray' }}>
{nilai.length}/{maxLength}
</div>
</div>
);
});
// Parent component
function App() {
const inputRef = useRef(null);
return (
<div>
<InputFancy ref={inputRef} placeholder="Ketik sesuatu..." maxLength={50} />
<br />
<button onClick={() => inputRef.current.focus()}>Fokus dari Parent</button>
<button onClick={() => inputRef.current.clear()}>Clear dari Parent</button>
<button onClick={() => alert(inputRef.current.getValue())}>
Ambil Nilai
</button>
</div>
);
}Penjelasan:
InputFancypakaiforwardRefsupaya bisa terima ref dari parentuseImperativeHandlemembatasi akses: parent cuma bisafocus(),clear(), dangetValue()- Parent nggak bisa akses DOM node langsung (lebih aman)
- Komponen tetap punya state internal (
nilai) yang dikelola sendiri
Sudah paham materi ini?
Tandai sebagai selesai untuk melacak progress-mu.