kodingup.camp

Beranda/Various Library Mastery

Optimasi State Turunan dan Selector

Modul 5 dari 134 menit bacaDerived State Optimization

Isi modul

    Kenapa ini penting

    Store sudah jalan, tapi aplikasi bisa tetap terasa lambat kalau komponen re-render tanpa alasan. Biasanya penyebabnya dua: (1) selector yang mengembalikan object atau array baru setiap kali, dan (2) menyimpan data yang sebenarnya bisa dihitung dari data lain, sehingga harus disinkronkan manual dan sering lupa.

    Modul ini mengajarkan cara berpikir "simpan sesedikit mungkin, hitung sisanya", plus alat useShallow dari Zustand untuk memilih beberapa nilai sekaligus tanpa re-render berlebihan.

    Konsep inti

    Nilai turunan vs nilai yang disimpan

    State turunan (derived state) adalah nilai yang bisa dihitung dari state lain. Contoh: dari array todos, kamu bisa menghitung jumlah tugas selesai, persentase progres, dan daftar yang sudah difilter.

    Analogi: di rapor, kamu menyimpan nilai tiap mata pelajaran. Rata-ratanya tidak perlu dicatat terpisah, cukup dihitung saat dibutuhkan. Kalau rata-rata ikut dicatat, setiap kali satu nilai diperbaiki kamu harus ingat memperbarui rata-ratanya juga. Lupa sekali, rapornya salah.

    Aturan praktis:

    Simpan di state Hitung (derive)
    todos jumlahSelesai, persenProgres
    filterStatus, search todosTerfilter
    items keranjang totalHarga, jumlahItem

    Masalah selector yang membuat object baru

    Zustand membandingkan hasil selector dengan Object.is. Dua object dengan isi sama tetap dianggap berbeda kalau referensinya berbeda:

    Object.is({ a: 1 }, { a: 1 }); // → false
    

    Jadi selector seperti ini bermasalah:

    const { theme, sidebarOpen } = useStore((s) => ({ theme: s.theme, sidebarOpen: s.sidebarOpen }));
    

    Setiap kali store berubah (bahkan field lain), selector membuat object baru, dianggap "berubah", komponen re-render. Di Zustand v5, selector yang selalu mengembalikan referensi baru bahkan bisa memicu error "Maximum update depth exceeded" (infinite loop), karena React melihat snapshot store terus berubah.

    Solusi 1: satu selector per nilai

    Cara paling sederhana, panggil hook beberapa kali, masing-masing mengembalikan nilai primitif atau referensi yang stabil:

    const theme = useStore((s) => s.theme);
    const sidebarOpen = useStore((s) => s.sidebarOpen);
    

    Solusi 2: useShallow

    Kalau ingin mengambil beberapa nilai dalam satu panggilan, bungkus selector dengan useShallow. Ia membandingkan isi object/array satu level (shallow), bukan referensinya:

    import { useShallow } from "zustand/react/shallow";
    
    const { theme, sidebarOpen } = useStore(
      useShallow((s) => ({ theme: s.theme, sidebarOpen: s.sidebarOpen }))
    );
    

    useShallow juga bisa diimpor dari "zustand/shallow"; keduanya menunjuk ke hook yang sama di v5.

    Menghitung nilai turunan dengan benar

    Kalau hasil selector adalah angka atau boolean (primitif), aman langsung dihitung di selector:

    const jumlahSelesai = useTodoStore((s) => s.todos.filter((t) => t.done).length);
    

    Hasilnya angka, jadi Object.is(3, 3) bernilai true dan tidak ada re-render berlebihan.

    Kalau hasilnya array baru (misalnya hasil filter), jangan dihitung di dalam selector. Ambil bahan mentahnya, lalu hitung dengan useMemo di komponen:

    const todos = useTodoStore((s) => s.todos);
    const status = useTodoStore((s) => s.status);
    const terfilter = useMemo(() => todos.filter(/* ... */), [todos, status]);
    

    useMemo hanya menghitung ulang kalau todos atau status berubah. Catatan: kalau proyekmu memakai React Compiler, banyak memoisasi dilakukan otomatis, tapi memahami prinsipnya tetap penting.

    Custom hook selector

    Supaya logika tidak tersebar, kumpulkan selector yang sering dipakai menjadi custom hook di file store:

    export const useJumlahSelesai = () => useTodoStore((s) => s.todos.filter((t) => t.done).length);
    

    Cara mengukur re-render

    Sebelum mengoptimasi, buktikan dulu ada masalah. Tiga cara sederhana:

    1. console.log di badan komponen. Cara paling cepat untuk melihat kapan sebuah komponen dirender. Ingat, di mode development dengan StrictMode, React sengaja merender dua kali saat mount untuk membantu menemukan bug, jadi jangan kaget melihat log ganda di awal.
    2. React DevTools → Profiler. Rekam interaksi (misalnya mengetik di kolom cari), lalu lihat komponen mana yang dirender dan berapa lama.
    3. Opsi "Highlight updates when components render" di pengaturan React DevTools. Komponen yang dirender akan berkedip, sehingga render yang tidak perlu langsung terlihat.

    Contoh nyata: di dashboard dengan 500 baris tugas, mengetik di kolom pencarian yang membuat seluruh daftar dan grafik ikut dirender bisa terasa patah-patah di HP murah. Dengan selector yang tepat, hanya komponen daftar yang dirender ulang, sedangkan grafik statistik diam karena angkanya tidak berubah.

    Contoh kode

    // src/stores/useTodoStore.js
    import { create } from "zustand";
    
    export const useTodoStore = create((set) => ({
      todos: [
        { id: 1, title: "Belajar Zustand", done: true },
        { id: 2, title: "Belajar useShallow", done: false },
        { id: 3, title: "Bikin dashboard", done: false },
      ],
      status: "semua", // "semua" | "aktif" | "selesai"
      search: "",
      setStatus: (status) => set({ status }),
      setSearch: (search) => set({ search }),
      toggle: (id) =>
        set((s) => ({
          todos: s.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
        })),
    }));
    
    // selector turunan yang hasilnya primitif: aman langsung di selector
    export const useProgress = () =>
      useTodoStore((s) => {
        if (s.todos.length === 0) return 0;
        const selesai = s.todos.filter((t) => t.done).length;
        return Math.round((selesai / s.todos.length) * 100);
      });
    
    // src/components/TodoPanel.jsx
    import { useMemo } from "react";
    import { useShallow } from "zustand/react/shallow";
    import { useProgress, useTodoStore } from "../stores/useTodoStore";
    
    function ProgressBar() {
      const persen = useProgress(); // angka → re-render hanya kalau angkanya berubah
      console.log("ProgressBar render");
      return (
        <div className="h-2 w-full rounded bg-slate-200">
          <div className="h-2 rounded bg-emerald-500" style={{ width: `${persen}%` }} />
        </div>
      );
    }
    
    function FilterBar() {
      // ambil beberapa nilai sekaligus dengan useShallow
      const { status, setStatus, setSearch } = useTodoStore(
        useShallow((s) => ({ status: s.status, setStatus: s.setStatus, setSearch: s.setSearch }))
      );
      return (
        <div className="flex gap-2">
          <input className="border px-2" placeholder="Cari..." onChange={(e) => setSearch(e.target.value)} />
          {["semua", "aktif", "selesai"].map((st) => (
            <button key={st} className={st === status ? "font-bold" : ""} onClick={() => setStatus(st)}>
              {st}
            </button>
          ))}
        </div>
      );
    }
    
    function TodoItems() {
      const todos = useTodoStore((s) => s.todos);
      const status = useTodoStore((s) => s.status);
      const search = useTodoStore((s) => s.search);
      const toggle = useTodoStore((s) => s.toggle);
    
      // array turunan dihitung di komponen dengan useMemo, BUKAN di selector
      const terfilter = useMemo(() => {
        const q = search.toLowerCase();
        return todos
          .filter((t) => (status === "aktif" ? !t.done : status === "selesai" ? t.done : true))
          .filter((t) => t.title.toLowerCase().includes(q));
      }, [todos, status, search]);
    
      return (
        <ul>
          {terfilter.map((t) => (
            <li key={t.id} onClick={() => toggle(t.id)} className={t.done ? "line-through" : ""}>
              {t.title}
            </li>
          ))}
        </ul>
      );
    }
    
    export default function TodoPanel() {
      return (
        <div className="space-y-4 p-6">
          <ProgressBar />
          <FilterBar />
          <TodoItems />
        </div>
      );
    }
    // → Awal: progres 33% (1 dari 3 selesai).
    // → Mengetik di kolom cari: ProgressBar TIDAK re-render karena persennya tetap.
    // → Klik "Belajar useShallow": progres menjadi 67% dan ProgressBar re-render.
    

    Kesalahan umum

    Latihan

    1. Mudah, Buat custom hook useJumlahAktif() yang mengembalikan jumlah tugas yang belum selesai dari useTodoStore.
    2. Sedang, Ubah selector berikut agar tidak memicu re-render berlebihan: const { todos, status } = useTodoStore((s) => ({ todos: s.todos, status: s.status })). Tulis dua versi perbaikan.
    3. Menantang, Untuk store keranjang dari modul sebelumnya, buat komponen CartSummary yang menampilkan total harga dan jumlah item, dan pastikan ia tidak re-render ketika field lain di store (misalnya kupon) berubah. Jelaskan kenapa solusimu aman.
    Lihat solusi
    // 1.
    export const useJumlahAktif = () => useTodoStore((s) => s.todos.filter((t) => !t.done).length);
    // hasil angka → aman, Object.is membandingkan nilai
    
    // 2a. Selector terpisah
    const todos = useTodoStore((s) => s.todos);
    const status = useTodoStore((s) => s.status);
    
    // 2b. useShallow
    import { useShallow } from "zustand/react/shallow";
    const { todos, status } = useTodoStore(
      useShallow((s) => ({ todos: s.todos, status: s.status }))
    );
    
    // 3. CartSummary
    function CartSummary() {
      const total = useCartStore((s) => s.items.reduce((sum, i) => sum + i.harga * i.qty, 0));
      const jumlah = useCartStore((s) => s.items.reduce((sum, i) => sum + i.qty, 0));
      return (
        <p>
          {jumlah} item - Rp{total.toLocaleString("id-ID")}
        </p>
      );
    }
    // → 2 item, Rp30.000
    // Aman karena kedua selector mengembalikan angka. Saat kupon berubah,
    // selector dihitung ulang tapi hasilnya angka yang sama, Object.is true, jadi tidak re-render.
    

    Ringkasan

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.