kodingup.camp

Beranda/Various Library Mastery

Dasar-Dasar Store Terpusat dengan Zustand

Modul 4 dari 134 menit bacaCentralized Store Fundamentals

Isi modul

    Kenapa ini penting

    Server state sudah diurus TanStack Query. Tapi masih ada client state yang dipakai di banyak tempat: tema gelap, user yang sedang login, filter daftar tugas, isi keranjang, status sidebar. Kalau disimpan di useState komponen paling atas lalu dioper lewat props ke bawah berlapis-lapis, kamu kena prop drilling: komponen di tengah menerima props yang tidak ia pakai, hanya untuk diteruskan.

    useContext bisa membantu, tapi punya kelemahan: setiap kali nilai context berubah, semua komponen yang memakai context itu ikut re-render, walaupun yang mereka butuhkan tidak berubah. Untuk state yang sering berubah, ini bisa membuat aplikasi terasa berat.

    Zustand (bahasa Jerman untuk "keadaan") adalah library state global yang sangat kecil (sekitar 1 KB), tanpa Provider, dan memungkinkan komponen berlangganan hanya potongan state yang ia butuhkan. Versi yang dipakai: zustand 5.0.16.

    Konsep inti

    Analogi: papan tulis di ruang guru

    Bayangkan sekolah punya satu papan tulis di ruang guru berisi jadwal, pengumuman, dan daftar piket. Setiap guru bisa melihat bagian papan yang relevan untuknya dan bisa menulis lewat "petugas papan" yang ditunjuk. Guru matematika hanya peduli jadwal matematika; kalau daftar piket berubah, ia tidak perlu diberi tahu. Papan itu adalah store, bagian yang dilihat tiap guru adalah selector, dan petugas papan adalah action.

    Membuat store dengan create

    import { create } from "zustand";
    
    const useCounterStore = create((set, get) => ({
      // state
      count: 0,
      // action
      increment: () => set((state) => ({ count: state.count + 1 })),
      reset: () => set({ count: 0 }),
      double: () => set({ count: get().count * 2 }),
    }));
    

    Membaca state dengan selector

    const count = useCounterStore((state) => state.count);
    const increment = useCounterStore((state) => state.increment);
    

    Fungsi (state) => state.count disebut selector. Komponen hanya akan re-render kalau hasil selector berubah (dibandingkan dengan Object.is). Kalau komponen hanya memilih increment, ia tidak akan re-render saat count berubah, karena fungsi increment tetap sama.

    Kamu bisa menulis const store = useCounterStore() tanpa selector, tapi itu berarti berlangganan seluruh store: setiap perubahan apa pun memicu re-render. Hindari ini kecuali store-nya sangat kecil.

    Membaca dan mengubah di luar komponen

    Hook Zustand juga punya getState() dan setState(), berguna di file biasa (misalnya interceptor axios yang butuh token):

    const token = useAuthStore.getState().token;
    useAuthStore.setState({ token: null });
    

    Ini tidak bisa dilakukan dengan Context biasa, dan nanti sangat berguna di modul axios.

    Perbandingan singkat dengan Redux Toolkit

    Redux adalah library state global paling tua dan masih banyak dipakai di perusahaan besar. Redux Toolkit (RTK, versi 2.13.0) adalah cara resmi modern menulis Redux.

    Aspek Zustand Redux Toolkit
    Ukuran sekitar 1 KB lebih besar (Redux + Immer + RTK)
    Provider Tidak perlu Wajib <Provider store={store}>
    Boilerplate Sangat sedikit Slice, reducer, configureStore, dispatch
    Cara ubah state Panggil action langsung dispatch(action())
    DevTools Lewat middleware Bawaan, sangat lengkap
    Cocok untuk Proyek kecil sampai menengah, tim kecil Tim besar, aturan ketat, alur data kompleks
    Data server Pasangkan dengan TanStack Query Punya RTK Query sendiri

    Kalau kamu melamar kerja dan melihat Redux di deskripsi, konsep di modul ini (store, state, action, selector) tetap berlaku. Yang berbeda hanyalah cara menulisnya.

    Contoh kode

    // src/stores/useThemeStore.js
    import { create } from "zustand";
    
    export const useThemeStore = create((set) => ({
      theme: "light",
      sidebarOpen: true,
      toggleTheme: () =>
        set((state) => ({ theme: state.theme === "light" ? "dark" : "light" })),
      toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
    }));
    
    // src/components/Header.jsx
    import { useThemeStore } from "../stores/useThemeStore";
    
    export function Header() {
      // berlangganan hanya theme dan action toggleTheme
      const theme = useThemeStore((s) => s.theme);
      const toggleTheme = useThemeStore((s) => s.toggleTheme);
      console.log("Header render");
    
      return (
        <header className="flex items-center justify-between border-b p-4">
          <h1 className="font-bold">Dashboard</h1>
          <button className="rounded border px-3 py-1" onClick={toggleTheme}>
            Tema: {theme}
          </button>
        </header>
      );
    }
    
    // src/components/Sidebar.jsx
    import { useThemeStore } from "../stores/useThemeStore";
    
    export function Sidebar() {
      const open = useThemeStore((s) => s.sidebarOpen);
      const toggleSidebar = useThemeStore((s) => s.toggleSidebar);
      console.log("Sidebar render");
    
      return (
        <aside className={open ? "w-56 border-r p-4" : "w-12 border-r p-2"}>
          <button onClick={toggleSidebar}>{open ? "«" : "»"}</button>
          {open && <p className="mt-4">Menu</p>}
        </aside>
      );
    }
    // → Klik tombol tema: console hanya mencetak "Header render".
    // → Klik tombol sidebar: console hanya mencetak "Sidebar render".
    // Tidak ada Provider, tidak ada props yang dioper.
    

    Sebagai pembanding, ini penulisan yang setara dengan Redux Toolkit:

    // Redux Toolkit 2.x, hanya untuk perbandingan
    import { configureStore, createSlice } from "@reduxjs/toolkit";
    import { Provider, useDispatch, useSelector } from "react-redux";
    
    const themeSlice = createSlice({
      name: "theme",
      initialState: { theme: "light" },
      reducers: {
        // RTK memakai Immer, jadi "mengubah langsung" di sini aman
        toggleTheme: (state) => {
          state.theme = state.theme === "light" ? "dark" : "light";
        },
      },
    });
    
    const store = configureStore({ reducer: { theme: themeSlice.reducer } });
    const { toggleTheme } = themeSlice.actions;
    
    function ThemeButton() {
      const theme = useSelector((state) => state.theme.theme);
      const dispatch = useDispatch();
      return <button onClick={() => dispatch(toggleTheme())}>Tema: {theme}</button>;
    }
    
    export default function App() {
      return (
        <Provider store={store}>
          <ThemeButton />
        </Provider>
      );
    }
    

    Kesalahan umum

    Latihan

    1. Mudah, Buat store useCounterStore dengan count, increment, decrement, dan reset. Tampilkan di satu komponen dengan tiga tombol.
    2. Sedang, Buat store useFilterStore berisi status ("semua" | "aktif" | "selesai") dan search (string), beserta action setStatus dan setSearch. Buat komponen FilterBar (mengubah) dan FilterInfo (menampilkan) di tempat terpisah.
    3. Menantang, Buat store keranjang belanja dengan items (array { id, nama, harga, qty }) dan action addItem (kalau id sudah ada, tambah qty), removeItem, dan clear. Pastikan semua update immutable.
    Lihat solusi
    // 1. Counter
    import { create } from "zustand";
    
    const useCounterStore = create((set) => ({
      count: 0,
      increment: () => set((s) => ({ count: s.count + 1 })),
      decrement: () => set((s) => ({ count: s.count - 1 })),
      reset: () => set({ count: 0 }),
    }));
    
    export function Counter() {
      const count = useCounterStore((s) => s.count);
      const increment = useCounterStore((s) => s.increment);
      const decrement = useCounterStore((s) => s.decrement);
      const reset = useCounterStore((s) => s.reset);
      return (
        <div>
          <p>{count}</p>
          <button onClick={decrement}>-</button>
          <button onClick={increment}>+</button>
          <button onClick={reset}>Reset</button>
        </div>
      );
    }
    
    // 2. Filter store
    export const useFilterStore = create((set) => ({
      status: "semua",
      search: "",
      setStatus: (status) => set({ status }),
      setSearch: (search) => set({ search }),
    }));
    
    export function FilterBar() {
      const setStatus = useFilterStore((s) => s.setStatus);
      const setSearch = useFilterStore((s) => s.setSearch);
      return (
        <div>
          <input placeholder="Cari..." onChange={(e) => setSearch(e.target.value)} />
          {["semua", "aktif", "selesai"].map((st) => (
            <button key={st} onClick={() => setStatus(st)}>{st}</button>
          ))}
        </div>
      );
    }
    
    export function FilterInfo() {
      const status = useFilterStore((s) => s.status);
      const search = useFilterStore((s) => s.search);
      return <p>Filter: {status}, kata kunci: "{search}"</p>;
    }
    
    // 3. Keranjang
    export const useCartStore = create((set) => ({
      items: [],
      addItem: (produk) =>
        set((s) => {
          const ada = s.items.find((i) => i.id === produk.id);
          if (ada) {
            return {
              items: s.items.map((i) => (i.id === produk.id ? { ...i, qty: i.qty + 1 } : i)),
            };
          }
          return { items: [...s.items, { ...produk, qty: 1 }] };
        }),
      removeItem: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
      clear: () => set({ items: [] }),
    }));
    // addItem({id:1,nama:"Kopi",harga:15000}) dua kali → items: [{ id:1, nama:"Kopi", harga:15000, qty:2 }]
    

    Ringkasan

    Kuis pemahaman

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