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:
console.logdi badan komponen. Cara paling cepat untuk melihat kapan sebuah komponen dirender. Ingat, di mode development denganStrictMode, React sengaja merender dua kali saat mount untuk membantu menemukan bug, jadi jangan kaget melihat log ganda di awal.- React DevTools → Profiler. Rekam interaksi (misalnya mengetik di kolom cari), lalu lihat komponen mana yang dirender dan berapa lama.
- 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
- Selector mengembalikan object/array baru tanpa
useShallow. Menyebabkan re-render setiap perubahan store, bahkan bisa infinite loop di v5. Pakai beberapa selector terpisah atauuseShallow. - Melakukan
filter/mapdi dalam selector. Hasilnya array baru setiap kali. Ambil data mentah lewat selector, lalu hitung diuseMemo. - Menyimpan nilai turunan di store. Menyimpan
jumlahSelesaidi sampingtodosberarti setiap action harus ingat memperbaruinya. Hitung saja daritodos. - Mengira
useShallowmembandingkan secara mendalam. Ia hanya membandingkan satu level. Kalau isinya object bersarang yang dibuat ulang, tetap dianggap berubah. - Optimasi berlebihan sejak awal. Ukur dulu (React DevTools Profiler atau
console.logrender) sebelum mengoptimalkan. Kode yang jelas lebih penting daripada menghemat satu render.
Latihan
- Mudah, Buat custom hook
useJumlahAktif()yang mengembalikan jumlah tugas yang belum selesai dariuseTodoStore. - 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. - Menantang, Untuk store keranjang dari modul sebelumnya, buat komponen
CartSummaryyang menampilkan total harga dan jumlah item, dan pastikan ia tidak re-render ketika field lain di store (misalnyakupon) 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
- Simpan data mentah seminimal mungkin; hitung nilai turunan (jumlah, persen, hasil filter).
- Zustand membandingkan hasil selector dengan
Object.is; object/array baru selalu dianggap berubah. - Pilih beberapa nilai dengan selector terpisah atau
useShallowdarizustand/react/shallow. - Turunan berupa angka/boolean aman dihitung di selector; turunan berupa array hitung dengan
useMemo. - Ukur sebelum mengoptimasi; kumpulkan selector yang sering dipakai menjadi custom hook.