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 }),
}));
createmenerima fungsi yang mengembalikan object berisi state dan action sekaligus.setmengubah state. Zustand otomatis menggabungkan (merge) object yang kamu berikan dengan state lama di level teratas, jadiset({ count: 0 })tidak menghapus action. Namun merge ini hanya satu level: object bersarang tetap harus kamu salin sendiri.setbisa menerima object atau fungsi(state) => perubahan. Pakai bentuk fungsi kalau nilai baru bergantung pada nilai lama.getmembaca state terkini dari dalam action.- Hasil
createadalah hook, beri nama diawaliuse.
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
- Memanggil hook tanpa selector.
const { theme } = useThemeStore()membuat komponen re-render setiap ada perubahan apa pun di store. PakaiuseThemeStore((s) => s.theme). - Mengubah state secara langsung.
state.todos.push(x)di dalamsettidak membuat referensi baru sehingga komponen tidak tahu ada perubahan. Tulisset((s) => ({ todos: [...s.todos, x] })). - Lupa bahwa merge hanya satu level.
set({ filter: { status: "selesai" } })mengganti seluruhfilterlama. Kalaufilterpunya field lain, salin dulu:set((s) => ({ filter: { ...s.filter, status: "selesai" } })). - Menyimpan data server di Zustand. Daftar tugas dari API sebaiknya di TanStack Query. Zustand untuk hal seperti filter, tema, dan sesi login.
- Membuat store di dalam komponen.
create(...)dipanggil sekali di level modul (file terpisah), bukan di dalam fungsi komponen.
Latihan
- Mudah, Buat store
useCounterStoredengancount,increment,decrement, danreset. Tampilkan di satu komponen dengan tiga tombol. - Sedang, Buat store
useFilterStoreberisistatus("semua" | "aktif" | "selesai") dansearch(string), beserta actionsetStatusdansetSearch. Buat komponenFilterBar(mengubah) danFilterInfo(menampilkan) di tempat terpisah. - Menantang, Buat store keranjang belanja dengan
items(array{ id, nama, harga, qty }) dan actionaddItem(kalau id sudah ada, tambah qty),removeItem, danclear. 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
- Zustand: state global kecil tanpa Provider;
createmenghasilkan hook berisi state dan action. setmenggabungkan perubahan satu level; gunakan bentuk fungsi kalau bergantung pada state lama.- Selalu baca dengan selector supaya komponen hanya re-render saat potongan yang dipilih berubah.
getState()dansetState()bisa dipakai di luar komponen.- Redux Toolkit adalah alternatif yang lebih terstruktur (slice, dispatch, Provider), umum di tim besar.