Bab 8: Custom Hooks - Reusing Logic

4 menit baca

Apa 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.

jsx
// 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?

  1. DRY (Don't Repeat Yourself): Nggak perlu copy-paste logic yang sama di banyak komponen
  2. Abstraksi: Sembunyiin detail implementasi yang rumit di balik nama yang jelas
  3. Testable: Bisa di-test terpisah dari komponen
  4. Composable: Bisa digabung satu sama lain

Aturan Custom Hook

  1. Nama HARUS dimulai dengan use (useXxx). Ini bukan cuma konvensi, React dan linter butuh ini buat enforce rules of hooks.
  2. Boleh panggil hooks lain di dalamnya (useState, useEffect, useRef, bahkan custom hook lain)
  3. Harus dipanggil di top level komponen atau custom hook lain (sama kayak hooks bawaan)
jsx
// ✅ 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 dalamnya

Custom Hook Pertama: useToggle

Mari mulai dari yang simpel. Toggle (on/off) itu pattern yang sering banget dipakai:

jsx
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:

jsx
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.

jsx
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:

  1. User ketik "laptop" (l...a...p...t...o...p)
  2. Setiap huruf: input berubah, tapi queryDebounced belum (timer di-reset)
  3. User berhenti ngetik 300ms
  4. Timer selesai: queryDebounced jadi "laptop"
  5. Effect fetch jalan dengan query "laptop"

Tanpa debounce, fetch jalan 6 kali (setiap huruf). Dengan debounce, cuma 1 kali!

useFetch: Abstraksi Data Fetching

jsx
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

jsx
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

jsx
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:

jsx
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

jsx
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!

jsx
// 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.

jsx
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:

  1. Logic yang sama dipakai di 2+ komponen (DRY)
  2. Logic yang kompleks dan bikin komponen susah dibaca
  3. Logic yang bisa di-test terpisah
  4. Abstraksi yang punya nama jelas (useAuth, useCart, useForm)

Jangan bikin custom hook kalau:

  1. Logic cuma dipakai di satu tempat dan simpel
  2. Cuma buat "rapiin" kode tanpa reuse yang jelas
  3. Hook-nya terlalu generik sampai nggak jelas fungsinya

Contoh Lengkap: useForm

jsx
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

jsx
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.

jsx
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"

jsx
// ❌ 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

jsx
// ❌ 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

jsx
// ❌ 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

jsx
// ❌ 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

  1. Custom Hook = fungsi yang dimulai dengan use dan bisa panggil hooks lain
  2. Berbagi logic, bukan state. Setiap komponen punya instance sendiri.
  3. Bisa di-compose: hook bisa pakai hook lain
  4. Bikin kalau: logic dipakai di 2+ tempat, atau logic terlalu kompleks
  5. Nama harus deskriptif: useLocalStorage, useDebounce, useFetch
  6. 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
jsx
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
jsx
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 storage otomatis 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
jsx
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, data state di setiap komponen
  • Logic async yang konsisten di seluruh app
  • execute bisa 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.