Kenapa ini penting
Coba hitung berapa baris yang kamu tulis di modul sebelumnya hanya untuk satu daftar user: tiga useState, satu useEffect dengan ignore, try/catch/finally, retry. Sekarang bayangkan aplikasi punya 15 halaman yang masing-masing mengambil data. Menyalin pola itu 15 kali berarti 15 tempat yang harus diperbaiki kalau ada bug. Custom hook membungkus logika itu sekali, lalu setiap komponen cukup menulis satu baris. Di modul ini kita membuat tiga hook yang paling sering dibutuhkan: useFetch, useLocalStorage, dan useDebounce.
Konsep inti
Custom hook = fungsi yang memakai hook
Custom hook adalah fungsi JavaScript biasa dengan dua syarat: namanya diawali use dan di dalamnya memanggil hook lain. Yang dibagikan adalah logika, bukan state. Dua komponen yang memanggil useFetch(url) masing-masing punya state sendiri, seperti dua orang yang memakai resep sama tapi memasak di panci masing-masing.
Merancang API hook
Pikirkan hook dari sisi pemakai:
- Masukan apa? useFetch(url), useLocalStorage(key, nilaiAwal), useDebounce(value, delay).
- Keluaran apa? Object { data, loading, error, refetch } untuk banyak nilai bernama; array [nilai, setNilai] kalau ingin meniru useState dan memudahkan rename.
| Hook | Masukan | Keluaran | Kegunaan |
|---|---|---|---|
useFetch |
url |
{ data, loading, error, refetch } |
Mengambil data dengan state lengkap |
useLocalStorage |
key, nilaiAwal |
[nilai, setNilai] |
State yang tersimpan setelah refresh |
useDebounce |
value, delay |
nilai yang tertunda | Menunda pencarian sampai user berhenti mengetik |
useLocalStorage: lazy initial state
localStorage hanya menyimpan string, jadi nilai di-JSON.stringify saat menyimpan dan di-JSON.parse saat membaca. Membaca localStorage tidak perlu dilakukan setiap render; cukup sekali di awal. Caranya: berikan fungsi ke useState(() => ...) (lazy initializer). React hanya memanggil fungsi itu pada render pertama. Bungkus dengan try/catch karena data bisa rusak atau localStorage bisa diblokir browser (misalnya mode privat tertentu).
useDebounce: menunggu user berhenti
Kalau setiap ketikan langsung memicu request pencarian, mengetik "laptop" mengirim 6 request. Debounce artinya: tunggu sampai tidak ada perubahan selama X milidetik, baru pakai nilainya. Implementasinya memakai setTimeout di dalam effect dan clearTimeout di cleanup. Setiap ketikan baru membatalkan timer lama.
Analogi: lift yang menunggu beberapa detik setelah orang terakhir masuk sebelum pintu ditutup. Kalau ada yang masuk lagi, hitungan diulang.
Kapan pakai library?
useFetch buatan sendiri bagus untuk belajar dan proyek kecil. Untuk aplikasi produksi dengan cache, refetch otomatis saat tab kembali aktif, pagination, dan mutasi, tim biasanya memakai TanStack Query atau SWR. Setelah membuat useFetch sendiri, kamu akan paham masalah apa yang mereka selesaikan.
Menguji custom hook
Salah satu keuntungan memindahkan logika ke hook adalah kemudahan pengujian. Library seperti React Testing Library menyediakan renderHook untuk menjalankan hook tanpa membuat komponen tampilan. Kamu bisa menguji useDebounce dengan timer palsu, atau useLocalStorage dengan mengecek isi localStorage setelah setNilai dipanggil. Pengujian dibahas di materi tersendiri, tapi biasakan merancang hook yang masukan dan keluarannya jelas supaya mudah diuji nanti.
Menyusun hook dari hook lain
Custom hook boleh memanggil custom hook lain. Misalnya useProducts() bisa dibangun di atas useFetch dengan URL produk dan sedikit transformasi data (mengubah harga ke format Rupiah). Komponen cukup memanggil useProducts() tanpa tahu URL atau detail fetch. Ini membentuk lapisan: hook umum (useFetch) di src/hooks, hook khusus fitur (useProducts) di folder fitur, dan komponen hanya berurusan dengan tampilan.
Tanda hook perlu dipecah
Kalau nama hook sulit dibuat tanpa kata "dan" (misalnya "mengambil produk dan menyimpan filter dan mengatur modal"), itu tanda hook mengurus terlalu banyak hal. Pecah menjadi beberapa hook kecil yang masing-masing punya satu tanggung jawab, lalu gabungkan di komponen atau di hook yang lebih tinggi.
Contoh kode
// src/hooks/useFetch.js
import { useCallback, useEffect, useState } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [percobaan, setPercobaan] = useState(0);
useEffect(() => {
if (!url) return; // url kosong → tidak fetch
const controller = new AbortController();
async function load() {
setLoading(true);
setError(null);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`Status ${res.status}`);
setData(await res.json());
} catch (err) {
if (err.name !== 'AbortError') setError(err);
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}
load();
return () => controller.abort();
}, [url, percobaan]);
// useCallback menjaga identitas fungsi tetap sama antar render (dibahas di modul 12)
const refetch = useCallback(() => setPercobaan((n) => n + 1), []);
return { data, loading, error, refetch };
}
// src/hooks/useLocalStorage.js
import { useEffect, useState } from 'react';
export function useLocalStorage(key, nilaiAwal) {
// lazy initializer: hanya dijalankan pada render pertama
const [nilai, setNilai] = useState(() => {
try {
const tersimpan = localStorage.getItem(key);
return tersimpan !== null ? JSON.parse(tersimpan) : nilaiAwal;
} catch {
return nilaiAwal; // data rusak atau storage diblokir
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(nilai));
} catch {
// storage penuh/diblokir: aplikasi tetap jalan tanpa menyimpan
}
}, [key, nilai]);
return [nilai, setNilai];
}
// src/hooks/useDebounce.js
import { useEffect, useState } from 'react';
export function useDebounce(value, delay = 400) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer); // ketikan baru → batalkan timer lama
}, [value, delay]);
return debounced;
}
Menggabungkan ketiganya: pencarian post dengan riwayat yang tersimpan.
// SearchPosts.jsx
import { useState } from 'react';
import { useFetch } from './hooks/useFetch.js';
import { useDebounce } from './hooks/useDebounce.js';
import { useLocalStorage } from './hooks/useLocalStorage.js';
export default function SearchPosts() {
const [query, setQuery] = useState('');
const queryTertunda = useDebounce(query, 500);
const [riwayat, setRiwayat] = useLocalStorage('riwayat-cari', []);
const url = queryTertunda
? `https://jsonplaceholder.typicode.com/posts?userId=${encodeURIComponent(queryTertunda)}`
: null;
const { data, loading, error, refetch } = useFetch(url);
const simpan = () => {
if (query && !riwayat.includes(query)) setRiwayat([query, ...riwayat].slice(0, 5));
};
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onBlur={simpan}
placeholder="Masukkan userId (1-10)"
/>
<p>Riwayat: {riwayat.join(', ') || '-'}</p>
{!url && <p>Ketik userId untuk mencari.</p>}
{url && loading && <p>Mencari...</p>}
{url && error && (
<p>
Gagal: {error.message} <button onClick={refetch}>Coba lagi</button>
</p>
)}
{url && !loading && !error && data && (
<ul>{data.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
)}
</div>
);
}
// Ketik "3" lalu tunggu 0,5 detik → 10 judul post milik user 3
// Refresh halaman → riwayat "3" masih ada (tersimpan di localStorage)
Kesalahan umum
- Mengira custom hook berbagi state antar komponen. Setiap pemanggilan punya state terpisah. Untuk state yang benar-benar bersama, pakai lifting state atau Context.
- Membaca
localStoragedi setiap render.useState(JSON.parse(localStorage.getItem(key)))menjalankan parse setiap render. Pakai lazy initializeruseState(() => ...). - Lupa
clearTimeoutdiuseDebounce. Tanpa cleanup, semua timer tetap berjalan dan nilai berubah beberapa kali; debounce tidak berfungsi. - Memberi object/array baru sebagai dependency hook.
useFetch(buildUrl({ page }))tidak masalah karena hasilnya string, tapi meneruskan object literal ke dependency effect di dalam hook membuat effect jalan setiap render. Gunakan nilai primitif seperti string URL. - Hook mengembalikan terlalu banyak hal. Hook yang mengembalikan 12 nilai biasanya mengurus terlalu banyak urusan. Pecah jadi beberapa hook.
Latihan
- Mudah, Buat hook
useToggle(awal)yang mengembalikan[nilai, toggle]. - Sedang, Pakai
useLocalStorageuntuk menyimpan pilihan tema (terang/gelap) sehingga tetap sama setelah refresh. - Menantang, Buat hook
useWindowWidth()yang mengembalikan lebar jendela terkini dan membersihkan event listener saat unmount, lalu gabungkan denganuseDebounceagar nilai hanya berubah setelah user berhenti me-resize selama 200 ms.
Lihat solusi
// 1.
import { useState } from 'react';
export function useToggle(awal = false) {
const [nilai, setNilai] = useState(awal);
const toggle = () => setNilai((v) => !v);
return [nilai, toggle];
}
// const [buka, toggleBuka] = useToggle(); → buka false, toggleBuka() → true
// 2.
import { useLocalStorage } from './hooks/useLocalStorage.js';
export function TemaSwitcher() {
const [tema, setTema] = useLocalStorage('tema', 'terang');
return (
<div style={{ background: tema === 'gelap' ? '#111' : '#fff', color: tema === 'gelap' ? '#fff' : '#111' }}>
<button onClick={() => setTema(tema === 'terang' ? 'gelap' : 'terang')}>Tema: {tema}</button>
</div>
);
}
// pilih gelap, refresh → tetap gelap
// 3.
import { useEffect, useState } from 'react';
import { useDebounce } from './hooks/useDebounce.js';
export function useWindowWidth() {
const [lebar, setLebar] = useState(() => window.innerWidth);
useEffect(() => {
const onResize = () => setLebar(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return lebar;
}
export function InfoLebar() {
const lebar = useWindowWidth();
const lebarTenang = useDebounce(lebar, 200);
return <p>Lebar (setelah berhenti resize): {lebarTenang}px</p>;
}
Ringkasan
- Custom hook membungkus logika berulang sehingga komponen cukup memanggil satu baris.
- Setiap pemanggilan custom hook punya state sendiri; yang dibagikan adalah logikanya.
useFetchmengembalikan{ data, loading, error, refetch }dan membatalkan request lama denganAbortController.useLocalStoragememakai lazy initializer dantry/catchagar aman.useDebouncememakaisetTimeout+clearTimeoutuntuk menunda nilai sampai input tenang.