Bab 8: Custom Hooks - Reusing Logic
⏱ 4 menit bacaApa Itu Custom Hook?
Bayangin kamu kerja di dapur restoran. Setiap kali bikin nasi goreng, langkahnya sama: panaskan wajan, tumis bumbu, masukkan nasi, aduk, tambah kecap. Kalau setiap koki harus ingat semua langkah dari awal, capek dan rawan salah.
Solusinya? Bikin resep standar yang bisa dipakai semua koki. "Ikutin resep nasi goreng" = semua langkah otomatis termasuk.
Custom Hook itu resep standar di React. Kamu bungkus logic yang sering dipakai jadi satu fungsi yang bisa dipanggil di komponen mana aja.
// Custom Hook = fungsi yang namanya dimulai dengan "use"
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
function handleResize() {
setSize({ width: window.innerWidth, height: window.innerHeight });
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
// Pakai di komponen mana aja!
function Header() {
const { width } = useWindowSize();
return <h1>{width < 768 ? 'Mobile' : 'Desktop'}</h1>;
}
function Sidebar() {
const { width } = useWindowSize();
if (width < 768) return null; // Sembunyiin di mobile
return <aside>Sidebar</aside>;
}Kenapa Custom Hook Penting?
- DRY (Don't Repeat Yourself): Nggak perlu copy-paste logic yang sama di banyak komponen
- Abstraksi: Sembunyiin detail implementasi yang rumit di balik nama yang jelas
- Testable: Bisa di-test terpisah dari komponen
- Composable: Bisa digabung satu sama lain
Aturan Custom Hook
- Nama HARUS dimulai dengan
use(useXxx). Ini bukan cuma konvensi, React dan linter butuh ini buat enforce rules of hooks. - Boleh panggil hooks lain di dalamnya (useState, useEffect, useRef, bahkan custom hook lain)
- Harus dipanggil di top level komponen atau custom hook lain (sama kayak hooks bawaan)
// ✅ Benar: dimulai dengan "use"
function useLocalStorage(key, initialValue) { ... }
function useDebounce(value, delay) { ... }
function useFetch(url) { ... }
// ❌ Salah: nggak dimulai dengan "use"
function getLocalStorage(key) { ... } // React nggak tahu ini hook
function fetchData(url) { ... } // Nggak bisa pakai useState di dalamnyaCustom Hook Pertama: useToggle
Mari mulai dari yang simpel. Toggle (on/off) itu pattern yang sering banget dipakai:
import { useState, useCallback } from 'react';
function useToggle(nilaiAwal = false) {
const [aktif, setAktif] = useState(nilaiAwal);
// useCallback biar fungsi stabil (nggak bikin child re-render)
const toggle = useCallback(() => {
setAktif(prev => !prev);
}, []);
const nyalakan = useCallback(() => setAktif(true), []);
const matikan = useCallback(() => setAktif(false), []);
return { aktif, toggle, nyalakan, matikan };
}
// Pemakaian
function MenuDropdown() {
const { aktif: menuBuka, toggle: toggleMenu } = useToggle(false);
return (
<div>
<button onClick={toggleMenu}>
{menuBuka ? '✕ Tutup' : '☰ Menu'}
</button>
{menuBuka && (
<ul>
<li>Beranda</li>
<li>Produk</li>
<li>Kontak</li>
</ul>
)}
</div>
);
}
function DarkMode() {
const { aktif: gelap, toggle: toggleTema } = useToggle(false);
return (
<div style={{ background: gelap ? '#333' : '#fff', color: gelap ? '#fff' : '#333' }}>
<button onClick={toggleTema}>
{gelap ? '☀️ Light Mode' : '🌙 Dark Mode'}
</button>
</div>
);
}useLocalStorage: Simpan Data yang Persisten
Ini custom hook yang super berguna. Data tersimpan meskipun browser ditutup:
import { useState, useEffect } from 'react';
function useLocalStorage(key, nilaiAwal) {
// Inisialisasi: coba baca dari localStorage dulu
const [nilai, setNilai] = useState(() => {
try {
const tersimpan = localStorage.getItem(key);
return tersimpan !== null ? JSON.parse(tersimpan) : nilaiAwal;
} catch (error) {
console.error('Error baca localStorage:', error);
return nilaiAwal;
}
});
// Sync ke localStorage setiap nilai berubah
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(nilai));
} catch (error) {
console.error('Error tulis localStorage:', error);
}
}, [key, nilai]);
return [nilai, setNilai];
}
// Pemakaian: persis kayak useState, tapi persisten!
function Pengaturan() {
const [tema, setTema] = useLocalStorage('tema', 'light');
const [bahasa, setBahasa] = useLocalStorage('bahasa', 'id');
const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);
return (
<div>
<h2>Pengaturan</h2>
<label>Tema: </label>
<select value={tema} onChange={e => setTema(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<label>Bahasa: </label>
<select value={bahasa} onChange={e => setBahasa(e.target.value)}>
<option value="id">Indonesia</option>
<option value="en">English</option>
</select>
<label>Font Size: {fontSize}px</label>
<input
type="range"
min="12"
max="24"
value={fontSize}
onChange={e => setFontSize(Number(e.target.value))}
/>
<p style={{ fontSize: `${fontSize}px` }}>
Preview teks dengan ukuran {fontSize}px
</p>
<p style={{ color: 'gray', fontSize: '12px' }}>
(Coba refresh halaman, pengaturan tetap tersimpan!)
</p>
</div>
);
}useDebounce: Tunda Eksekusi
Debounce itu pattern di mana kamu nunggu user selesai melakukan sesuatu sebelum bereaksi. Contoh: nunggu user selesai ngetik sebelum search.
import { useState, useEffect } from 'react';
function useDebounce(nilai, delay = 500) {
const [nilaiDebounced, setNilaiDebounced] = useState(nilai);
useEffect(() => {
// Set timer: update nilaiDebounced setelah delay
const timerId = setTimeout(() => {
setNilaiDebounced(nilai);
}, delay);
// Cleanup: cancel timer kalau nilai berubah sebelum delay selesai
return () => {
clearTimeout(timerId);
};
}, [nilai, delay]);
return nilaiDebounced;
}
// Pemakaian
function SearchBar() {
const [input, setInput] = useState('');
const queryDebounced = useDebounce(input, 300); // Tunggu 300ms
const [hasil, setHasil] = useState([]);
const [loading, setLoading] = useState(false);
// Fetch cuma jalan kalau queryDebounced berubah (setelah user berhenti ngetik)
useEffect(() => {
if (queryDebounced.trim() === '') {
setHasil([]);
return;
}
let aktif = true;
setLoading(true);
fetch(`/api/search?q=${queryDebounced}`)
.then(res => res.json())
.then(data => {
if (aktif) {
setHasil(data);
setLoading(false);
}
});
return () => { aktif = false; };
}, [queryDebounced]); // Depend pada nilai yang sudah di-debounce
return (
<div>
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Cari produk..."
/>
{loading && <p>Mencari...</p>}
<ul>
{hasil.map(h => <li key={h.id}>{h.nama}</li>)}
</ul>
</div>
);
}Cara kerjanya:
- User ketik "laptop" (l...a...p...t...o...p)
- Setiap huruf:
inputberubah, tapiqueryDebouncedbelum (timer di-reset) - User berhenti ngetik 300ms
- Timer selesai:
queryDebouncedjadi "laptop" - Effect fetch jalan dengan query "laptop"
Tanpa debounce, fetch jalan 6 kali (setiap huruf). Dengan debounce, cuma 1 kali!
useFetch: Abstraksi Data Fetching
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let aktif = true;
setLoading(true);
setError(null);
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json();
})
.then(json => {
if (aktif) {
setData(json);
setLoading(false);
}
})
.catch(err => {
if (aktif) {
setError(err.message);
setLoading(false);
}
});
return () => { aktif = false; };
}, [url]);
return { data, loading, error };
}
// Pemakaian: super bersih!
function DaftarUser() {
const { data: users, loading, error } = useFetch('/api/users');
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
function DetailProduk({ produkId }) {
const { data: produk, loading, error } = useFetch(`/api/produk/${produkId}`);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<div>
<h2>{produk.nama}</h2>
<p>Harga: Rp{produk.harga.toLocaleString()}</p>
</div>
);
}useWindowSize: Responsive tanpa CSS
import { useState, useEffect } from 'react';
function useWindowSize() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return windowSize;
}
// Pemakaian
function ResponsiveLayout() {
const { width, height } = useWindowSize();
const isMobile = width < 768;
const isTablet = width >= 768 && width < 1024;
const isDesktop = width >= 1024;
return (
<div>
<p>Layar: {width} x {height}</p>
<p>Device: {isMobile ? '📱 Mobile' : isTablet ? '📟 Tablet' : '🖥️ Desktop'}</p>
{isMobile ? (
<nav>Menu Hamburger</nav>
) : (
<nav>Menu Horizontal Penuh</nav>
)}
</div>
);
}useOnlineStatus: Deteksi Koneksi Internet
import { useState, useEffect } from 'react';
function useOnlineStatus() {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
function handleOnline() { setOnline(true); }
function handleOffline() { setOnline(false); }
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return online;
}
// Pemakaian
function SaveButton() {
const online = useOnlineStatus();
return (
<button disabled={!online} onClick={() => simpanData()}>
{online ? '💾 Simpan' : '📡 Offline - Tidak bisa simpan'}
</button>
);
}
function StatusBar() {
const online = useOnlineStatus();
return (
<div style={{
padding: '5px 10px',
background: online ? '#c8e6c9' : '#ffcdd2',
textAlign: 'center'
}}>
{online ? '🟢 Online' : '🔴 Offline'}
</div>
);
}useInterval: setInterval yang Aman
setInterval di React itu tricky. Custom hook ini bikin hidup lebih mudah:
import { useEffect, useRef } from 'react';
function useInterval(callback, delay) {
const callbackRef = useRef(callback);
// Update ref setiap render (supaya callback selalu fresh)
useEffect(() => {
callbackRef.current = callback;
});
useEffect(() => {
if (delay === null) return; // null = pause interval
const id = setInterval(() => {
callbackRef.current(); // Panggil callback terbaru
}, delay);
return () => clearInterval(id);
}, [delay]); // Cuma restart kalau delay berubah
}
// Pemakaian: jauh lebih simpel!
function Jam() {
const [waktu, setWaktu] = useState(new Date());
useInterval(() => {
setWaktu(new Date());
}, 1000);
return <h1>{waktu.toLocaleTimeString('id-ID')}</h1>;
}
function Counter() {
const [count, setCount] = useState(0);
const [jalan, setJalan] = useState(true);
// null = pause, 1000 = jalan
useInterval(() => {
setCount(c => c + 1);
}, jalan ? 1000 : null);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setJalan(!jalan)}>
{jalan ? 'Pause' : 'Resume'}
</button>
</div>
);
}usePrevious: Ingat Nilai Sebelumnya
import { useRef, useEffect } from 'react';
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current; // Return nilai dari render sebelumnya
}
// Pemakaian
function HargaSaham({ harga }) {
const hargaSebelumnya = usePrevious(harga);
const naik = hargaSebelumnya !== undefined && harga > hargaSebelumnya;
const turun = hargaSebelumnya !== undefined && harga < hargaSebelumnya;
return (
<div>
<span style={{ color: naik ? 'green' : turun ? 'red' : 'black' }}>
{naik && '📈 '}
{turun && '📉 '}
Rp{harga.toLocaleString()}
</span>
{hargaSebelumnya !== undefined && (
<small> (sebelumnya: Rp{hargaSebelumnya.toLocaleString()})</small>
)}
</div>
);
}Composing Hooks: Hook yang Pakai Hook Lain
Kekuatan custom hooks: bisa digabung kayak LEGO!
// Hook kecil
function useDebounce(value, delay) { /* ... */ }
function useFetch(url) { /* ... */ }
function useLocalStorage(key, initial) { /* ... */ }
// Hook yang compose hook lain!
function useSearch(endpoint) {
const [query, setQuery] = useLocalStorage('lastSearch', '');
const debouncedQuery = useDebounce(query, 300);
const url = debouncedQuery
? `${endpoint}?q=${encodeURIComponent(debouncedQuery)}`
: null;
const { data, loading, error } = useFetch(url);
return {
query,
setQuery,
results: data || [],
loading,
error
};
}
// Pemakaian: super bersih!
function SearchPage() {
const { query, setQuery, results, loading, error } = useSearch('/api/search');
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
{loading && <p>Mencari...</p>}
{error && <p>Error: {error}</p>}
<ul>
{results.map(r => <li key={r.id}>{r.nama}</li>)}
</ul>
</div>
);
}Penting: Custom Hook Berbagi LOGIC, Bukan STATE
Ini yang sering salah dipahami. Kalau dua komponen pakai custom hook yang sama, mereka nggak berbagi state. Masing-masing punya state sendiri.
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = () => setCount(c => c + 1);
return { count, increment };
}
function KomponenA() {
const { count, increment } = useCounter(0);
// count ini MILIK KomponenA sendiri
return <button onClick={increment}>A: {count}</button>;
}
function KomponenB() {
const { count, increment } = useCounter(0);
// count ini MILIK KomponenB sendiri, BEDA dari KomponenA!
return <button onClick={increment}>B: {count}</button>;
}
// Klik tombol A nggak ngaruh ke B, dan sebaliknya!Analogi: Custom hook itu kayak resep masak. Dua koki bisa pakai resep yang sama, tapi masing-masing masak di wajan sendiri. Hasil masakan mereka independen.
Kalau mau berbagi state antar komponen, pakai Context atau state management library (bukan custom hook).
Kapan Bikin Custom Hook?
Bikin custom hook kalau:
- Logic yang sama dipakai di 2+ komponen (DRY)
- Logic yang kompleks dan bikin komponen susah dibaca
- Logic yang bisa di-test terpisah
- Abstraksi yang punya nama jelas (useAuth, useCart, useForm)
Jangan bikin custom hook kalau:
- Logic cuma dipakai di satu tempat dan simpel
- Cuma buat "rapiin" kode tanpa reuse yang jelas
- Hook-nya terlalu generik sampai nggak jelas fungsinya
Contoh Lengkap: useForm
import { useState, useCallback } from 'react';
function useForm(nilaiAwal, validasi = {}) {
const [values, setValues] = useState(nilaiAwal);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [submitting, setSubmitting] = useState(false);
const handleChange = useCallback((field) => (e) => {
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
setValues(prev => ({ ...prev, [field]: value }));
// Clear error saat user mulai ngetik
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
}, [errors]);
const handleBlur = useCallback((field) => () => {
setTouched(prev => ({ ...prev, [field]: true }));
// Validasi field ini
if (validasi[field]) {
const error = validasi[field](values[field], values);
setErrors(prev => ({ ...prev, [field]: error || '' }));
}
}, [validasi, values]);
const validateAll = useCallback(() => {
const newErrors = {};
let valid = true;
Object.keys(validasi).forEach(field => {
const error = validasi[field](values[field], values);
if (error) {
newErrors[field] = error;
valid = false;
}
});
setErrors(newErrors);
setTouched(Object.keys(nilaiAwal).reduce((acc, key) => ({ ...acc, [key]: true }), {}));
return valid;
}, [validasi, values, nilaiAwal]);
const handleSubmit = useCallback((onSubmit) => async (e) => {
e.preventDefault();
if (!validateAll()) return;
setSubmitting(true);
try {
await onSubmit(values);
} finally {
setSubmitting(false);
}
}, [validateAll, values]);
const reset = useCallback(() => {
setValues(nilaiAwal);
setErrors({});
setTouched({});
}, [nilaiAwal]);
return {
values,
errors,
touched,
submitting,
handleChange,
handleBlur,
handleSubmit,
reset
};
}
// Pemakaian
function FormRegistrasi() {
const {
values,
errors,
touched,
submitting,
handleChange,
handleBlur,
handleSubmit,
reset
} = useForm(
// Nilai awal
{ nama: '', email: '', password: '', setuju: false },
// Validasi
{
nama: (v) => !v ? 'Nama wajib diisi' : v.length < 3 ? 'Minimal 3 karakter' : '',
email: (v) => !v ? 'Email wajib diisi' : !v.includes('@') ? 'Email nggak valid' : '',
password: (v) => !v ? 'Password wajib diisi' : v.length < 8 ? 'Minimal 8 karakter' : '',
setuju: (v) => !v ? 'Harus setuju dengan syarat & ketentuan' : ''
}
);
async function onSubmit(data) {
// Simulasi kirim ke server
await new Promise(resolve => setTimeout(resolve, 1000));
alert(`Berhasil daftar! Nama: ${data.nama}, Email: ${data.email}`);
reset();
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input
value={values.nama}
onChange={handleChange('nama')}
onBlur={handleBlur('nama')}
placeholder="Nama"
/>
{touched.nama && errors.nama && (
<span style={{ color: 'red', fontSize: '12px' }}>{errors.nama}</span>
)}
</div>
<div>
<input
type="email"
value={values.email}
onChange={handleChange('email')}
onBlur={handleBlur('email')}
placeholder="Email"
/>
{touched.email && errors.email && (
<span style={{ color: 'red', fontSize: '12px' }}>{errors.email}</span>
)}
</div>
<div>
<input
type="password"
value={values.password}
onChange={handleChange('password')}
onBlur={handleBlur('password')}
placeholder="Password"
/>
{touched.password && errors.password && (
<span style={{ color: 'red', fontSize: '12px' }}>{errors.password}</span>
)}
</div>
<div>
<label>
<input
type="checkbox"
checked={values.setuju}
onChange={handleChange('setuju')}
onBlur={handleBlur('setuju')}
/>
Saya setuju dengan syarat & ketentuan
</label>
{touched.setuju && errors.setuju && (
<span style={{ color: 'red', fontSize: '12px' }}>{errors.setuju}</span>
)}
</div>
<button type="submit" disabled={submitting}>
{submitting ? 'Mendaftar...' : 'Daftar'}
</button>
<button type="button" onClick={reset}>Reset</button>
</form>
);
}Contoh: useMediaQuery
import { useState, useEffect } from 'react';
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => {
return window.matchMedia(query).matches;
});
useEffect(() => {
const mediaQuery = window.matchMedia(query);
function handleChange(e) {
setMatches(e.matches);
}
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [query]);
return matches;
}
// Pemakaian
function App() {
const isMobile = useMediaQuery('(max-width: 767px)');
const preferssDark = useMediaQuery('(prefers-color-scheme: dark)');
const isLandscape = useMediaQuery('(orientation: landscape)');
return (
<div>
<p>Mobile: {isMobile ? 'Ya' : 'Tidak'}</p>
<p>Dark mode: {preferssDark ? 'Ya' : 'Tidak'}</p>
<p>Landscape: {isLandscape ? 'Ya' : 'Tidak'}</p>
</div>
);
}Contoh: useClickOutside
Hook yang sering dipakai buat dropdown/modal: deteksi klik di luar elemen.
import { useEffect, useRef } from 'react';
function useClickOutside(callback) {
const ref = useRef(null);
useEffect(() => {
function handleClick(event) {
// Kalau klik di luar elemen yang di-ref
if (ref.current && !ref.current.contains(event.target)) {
callback();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [callback]);
return ref;
}
// Pemakaian
function Dropdown() {
const [buka, setBuka] = useState(false);
// Tutup dropdown kalau klik di luar
const dropdownRef = useClickOutside(() => setBuka(false));
return (
<div ref={dropdownRef}>
<button onClick={() => setBuka(!buka)}>Menu ▼</button>
{buka && (
<ul style={{ position: 'absolute', background: 'white', border: '1px solid #ccc' }}>
<li>Profil</li>
<li>Pengaturan</li>
<li>Logout</li>
</ul>
)}
</div>
);
}⚠️ Jebakan
Jebakan 1: Nama Nggak Dimulai dengan "use"
// ❌ React nggak tahu ini hook, rules of hooks nggak di-enforce
function getWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 }); // Error!
// useState nggak boleh dipanggil di fungsi biasa
}
// ✅ Harus dimulai dengan "use"
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 }); // OK!
}Jebakan 2: Pikir Custom Hook Berbagi State
// ❌ Salah paham: "kedua komponen pakai counter yang sama"
function useCounter() {
const [count, setCount] = useState(0);
return { count, setCount };
}
function A() {
const { count } = useCounter(); // count MILIK A
return <p>A: {count}</p>;
}
function B() {
const { count } = useCounter(); // count MILIK B (BEDA dari A!)
return <p>B: {count}</p>;
}
// Klik di A nggak ngaruh ke B!Jebakan 3: Hook yang Terlalu Besar
// ❌ Hook yang lakuin terlalu banyak hal
function useEverything() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [comments, setComments] = useState([]);
const [notifications, setNotifications] = useState([]);
const [theme, setTheme] = useState('light');
// ... 200 baris lagi
// Ini bukan hook, ini monster!
}
// ✅ Pecah jadi hook-hook kecil yang fokus
function useUser() { /* ... */ }
function usePosts(userId) { /* ... */ }
function useComments(postId) { /* ... */ }
function useNotifications() { /* ... */ }
function useTheme() { /* ... */ }Jebakan 4: Conditional Hook Call
// ❌ Hook nggak boleh dipanggil secara conditional!
function Komponen({ tampilkan }) {
if (tampilkan) {
const size = useWindowSize(); // ERROR! Hook di dalam if
}
}
// ✅ Panggil hook di top level, conditional di return
function Komponen({ tampilkan }) {
const size = useWindowSize(); // Selalu dipanggil
if (!tampilkan) return null;
return <p>Width: {size.width}</p>;
}Ringkasan
- Custom Hook = fungsi yang dimulai dengan
usedan bisa panggil hooks lain - Berbagi logic, bukan state. Setiap komponen punya instance sendiri.
- Bisa di-compose: hook bisa pakai hook lain
- Bikin kalau: logic dipakai di 2+ tempat, atau logic terlalu kompleks
- Nama harus deskriptif:
useLocalStorage,useDebounce,useFetch - Ikuti rules of hooks: panggil di top level, jangan di conditional/loop
🏋️ Challenge
Challenge 1: Bikin useCountdown Hook
Bikin custom hook useCountdown(targetDate) yang return sisa waktu (hari, jam, menit, detik) sampai tanggal target.
Hint: Pakai useInterval atau useEffect + setInterval. Hitung selisih antara targetDate dan Date.now().
Lihat Solusi
import { useState, useEffect } from 'react';
function useCountdown(targetDate) {
const [sisaWaktu, setSisaWaktu] = useState(() => hitungSisa(targetDate));
function hitungSisa(target) {
const selisih = new Date(target).getTime() - Date.now();
if (selisih <= 0) {
return { hari: 0, jam: 0, menit: 0, detik: 0, selesai: true };
}
return {
hari: Math.floor(selisih / (1000 * 60 * 60 * 24)),
jam: Math.floor((selisih % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)),
menit: Math.floor((selisih % (1000 * 60 * 60)) / (1000 * 60)),
detik: Math.floor((selisih % (1000 * 60)) / 1000),
selesai: false
};
}
useEffect(() => {
const intervalId = setInterval(() => {
const sisa = hitungSisa(targetDate);
setSisaWaktu(sisa);
if (sisa.selesai) {
clearInterval(intervalId);
}
}, 1000);
return () => clearInterval(intervalId);
}, [targetDate]);
return sisaWaktu;
}
// Pemakaian
function PromoCountdown() {
// Target: 7 hari dari sekarang
const target = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
const { hari, jam, menit, detik, selesai } = useCountdown(target);
if (selesai) {
return <p>🎉 Promo sudah berakhir!</p>;
}
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<h2>⏰ Promo Berakhir Dalam:</h2>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'center' }}>
<div style={{ background: '#333', color: 'white', padding: '10px 15px', borderRadius: '8px' }}>
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>{hari}</div>
<div style={{ fontSize: '12px' }}>Hari</div>
</div>
<div style={{ background: '#333', color: 'white', padding: '10px 15px', borderRadius: '8px' }}>
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>{String(jam).padStart(2, '0')}</div>
<div style={{ fontSize: '12px' }}>Jam</div>
</div>
<div style={{ background: '#333', color: 'white', padding: '10px 15px', borderRadius: '8px' }}>
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>{String(menit).padStart(2, '0')}</div>
<div style={{ fontSize: '12px' }}>Menit</div>
</div>
<div style={{ background: '#333', color: 'white', padding: '10px 15px', borderRadius: '8px' }}>
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>{String(detik).padStart(2, '0')}</div>
<div style={{ fontSize: '12px' }}>Detik</div>
</div>
</div>
</div>
);
}Challenge 2: Bikin useLocalStorage yang Sync Antar Tab
Upgrade useLocalStorage supaya kalau user buka 2 tab, perubahan di tab satu otomatis keliatan di tab lain.
Hint: Pakai event storage dari window. Event ini fire di tab LAIN (bukan tab yang melakukan perubahan).
Lihat Solusi
import { useState, useEffect, useCallback } from 'react';
function useLocalStorageSync(key, nilaiAwal) {
const [nilai, setNilai] = useState(() => {
try {
const tersimpan = localStorage.getItem(key);
return tersimpan !== null ? JSON.parse(tersimpan) : nilaiAwal;
} catch {
return nilaiAwal;
}
});
// Tulis ke localStorage saat nilai berubah
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(nilai));
} catch (error) {
console.error('Gagal tulis localStorage:', error);
}
}, [key, nilai]);
// Listen perubahan dari tab lain
useEffect(() => {
function handleStorageChange(event) {
// Cek apakah key yang berubah = key kita
if (event.key === key && event.newValue !== null) {
try {
const nilaiBaru = JSON.parse(event.newValue);
setNilai(nilaiBaru);
} catch {
// Ignore parse error
}
}
}
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);
// Wrapper setNilai yang juga dispatch event buat tab yang sama
const setNilaiSync = useCallback((newValue) => {
setNilai(newValue);
}, []);
return [nilai, setNilaiSync];
}
// Pemakaian
function App() {
const [tema, setTema] = useLocalStorageSync('app-tema', 'light');
const [counter, setCounter] = useLocalStorageSync('app-counter', 0);
return (
<div style={{
background: tema === 'dark' ? '#333' : '#fff',
color: tema === 'dark' ? '#fff' : '#333',
padding: '20px',
minHeight: '100vh'
}}>
<h2>Sync Antar Tab</h2>
<p>Buka halaman ini di 2 tab, lalu ubah tema/counter di salah satu tab.</p>
<button onClick={() => setTema(tema === 'dark' ? 'light' : 'dark')}>
Toggle Tema ({tema})
</button>
<div>
<button onClick={() => setCounter(counter - 1)}>-</button>
<span style={{ margin: '0 10px' }}>{counter}</span>
<button onClick={() => setCounter(counter + 1)}>+</button>
</div>
</div>
);
}Penjelasan:
- Event
storageotomatis fire di tab lain saat localStorage berubah - Di tab yang melakukan perubahan, event NGGAK fire (by design browser)
- Jadi kita update state lokal lewat
setNilai, dan tab lain dapet update lewat event
Challenge 3: Bikin useAsync Hook
Bikin hook generik useAsync(asyncFunction) yang handle loading, error, dan data. Harus bisa di-trigger manual (bukan otomatis saat mount).
Hint: Return object dengan { data, loading, error, execute }. execute adalah fungsi yang bisa dipanggil kapan aja.
Lihat Solusi
import { useState, useCallback } from 'react';
function useAsync() {
const [state, setState] = useState({
data: null,
loading: false,
error: null
});
const execute = useCallback(async (asyncFunction) => {
setState({ data: null, loading: true, error: null });
try {
const result = await asyncFunction();
setState({ data: result, loading: false, error: null });
return result;
} catch (error) {
setState({ data: null, loading: false, error: error.message });
throw error;
}
}, []);
const reset = useCallback(() => {
setState({ data: null, loading: false, error: null });
}, []);
return { ...state, execute, reset };
}
// Pemakaian 1: Form submit
function FormKontak() {
const [nama, setNama] = useState('');
const [pesan, setPesan] = useState('');
const { data, loading, error, execute, reset } = useAsync();
async function handleSubmit(e) {
e.preventDefault();
await execute(async () => {
const res = await fetch('/api/kontak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nama, pesan })
});
if (!res.ok) throw new Error('Gagal mengirim pesan');
return res.json();
});
// Reset form setelah berhasil
setNama('');
setPesan('');
}
return (
<form onSubmit={handleSubmit}>
<input value={nama} onChange={e => setNama(e.target.value)} placeholder="Nama" />
<textarea value={pesan} onChange={e => setPesan(e.target.value)} placeholder="Pesan" />
<button type="submit" disabled={loading}>
{loading ? 'Mengirim...' : 'Kirim'}
</button>
{error && <p style={{ color: 'red' }}>Error: {error}</p>}
{data && <p style={{ color: 'green' }}>✅ Pesan terkirim!</p>}
</form>
);
}
// Pemakaian 2: Load data on demand
function ProfilUser() {
const [userId, setUserId] = useState('');
const { data: user, loading, error, execute } = useAsync();
function handleCari() {
execute(async () => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('User tidak ditemukan');
return res.json();
});
}
return (
<div>
<input
value={userId}
onChange={e => setUserId(e.target.value)}
placeholder="Masukkan User ID"
/>
<button onClick={handleCari} disabled={loading}>
{loading ? 'Mencari...' : 'Cari User'}
</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
{user && (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
)}
</div>
);
}Kenapa useAsync berguna:
- Nggak perlu bikin
loading,error,datastate di setiap komponen - Logic async yang konsisten di seluruh app
executebisa dipanggil dari event handler (nggak otomatis saat mount)- Bisa di-reuse buat form submit, load data, atau operasi async apapun
Sudah paham materi ini?
Tandai sebagai selesai untuk melacak progress-mu.