Kamu pernah lihat Context bikin seluruh pohon komponen re-render cuma karena satu boolean berubah? Atau mulai mengoper prop lewat tiga lapis komponen sekadar untuk meneruskan nilai keranjang belanja? Saya pernah, dan dua-duanya bikin refactor jadi momok. Zustand menyelesaikan ini dengan cara yang nyaris terlalu sederhana untuk dipercaya: satu fungsi create, satu store, dan komponen memilih sendiri bagian state mana yang mereka pedulikan. Bundlenya sekitar 1 KB, tidak butuh Provider pembungkus apa pun.
Kenapa Zustand, Bukan Context atau Redux?
Context bagus untuk data yang jarang berubah, seperti tema atau bahasa. Masalahnya muncul ketika satu Context menampung banyak nilai lalu satu nilai berubah — semua komponen yang memakai Context itu ikut re-render, termasuk yang cuma butuh nilai lain. Redux menyelesaikan ini lewat selector, tapi bayarannya boilerplate: action types, action creators, reducer, dan Provider. Zustand mengambil model selector dari Redux dan membuang semua sisanya. Store cuma fungsi biasa, bahkan bisa dipakai di luar React. Kalau kamu belum nyaman dengan konsep komponen dan state, mampir dulu ke panduan React untuk pemula.
Siapkan Proyek
Mulai dari proyek Vite + React. Jalankan empat perintah ini di terminal:
npm create vite@latest toko-ku -- --template react
cd toko-ku
npm install
npm install zustand
Perintah terakhir menambahkan zustand ke package.json. Semua contoh di bawah memakai API create yang stabil sejak v4 dan tetap berlaku di v5.
Store Pertama: Counter
Buat file src/store.js. Store Zustand cuma satu pemanggilan create yang menerima fungsi set:
import { create } from 'zustand'
export const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}))
Fungsi set menerima objek baru atau sebuah fungsi. Kalau nilai barunya bergantung pada nilai lama — seperti increment — selalu pakai bentuk fungsi (state) => ({...}) supaya aman dari kondisi balapan. Untuk reset yang tidak bergantung nilai lama, cukup kirim objek.
Jangan baca state dari variabel luar lalu menimpanya. Kalau update bergantung pada state sebelumnya, selalu lewat fungsi (state) => .... Ini penting begitu action mulai dipanggil secara bersamaan.
Pakai Store di Komponen
Ganti isi src/App.jsx dengan ini:
import { useCounterStore } from './store'
function App() {
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)
const decrement = useCounterStore((state) => state.decrement)
const reset = useCounterStore((state) => state.reset)
return (
<div style={{ padding: 24, fontFamily: 'sans-serif' }}>
<h1>{count}</h1>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
</div>
)
}
export default App
Jalankan npm run dev lalu klik tombolnya. Tidak ada <Provider>, tidak ada useContext — komponen langsung memanggil hook dan mengambil apa yang dibutuhkan lewat selector (state) => state.count.
State Loading, Error, dan Fetch Data
Counter bagus untuk pemanasan, tapi aplikasi nyata butuh data dari API. Zustand tidak peduli apakah action kamu sinkron atau async. Tambahkan store kedua di src/postStore.js:
import { create } from 'zustand'
export const usePostStore = create((set) => ({
posts: [],
loading: false,
error: null,
fetchPosts: async () => {
set({ loading: true, error: null })
try {
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const posts = await res.json()
set({ posts, loading: false })
} catch (err) {
set({ error: err.message, loading: false })
}
},
}))
Action async adalah fungsi biasa yang di dalamnya kamu bebas memanggil set berkali-kali. set tetap berfungsi sama, tidak peduli action-nya async. Kalau kamu belum akrab dengan await dan try/catch, lihat panduan async/await di JavaScript.
Hindari Re-render dengan useShallow
Bahaya tersembunyi muncul saat selector mengembalikan objek baru. Contoh ini bikin komponen re-render tanpa henti karena setiap render menghasilkan objek baru:
// Salah: objek baru dibuat setiap render -> re-render tanpa henti
const { posts, loading } = usePostStore((state) => ({
posts: state.posts,
loading: state.loading,
}))
Perbaikannya pakai useShallow dari zustand/react/shallow. Dia membandingkan isi objek, bukan referensinya:
import { useShallow } from 'zustand/react/shallow'
function PostList() {
const { posts, loading, error, fetchPosts } = usePostStore(
useShallow((state) => ({
posts: state.posts,
loading: state.loading,
error: state.error,
fetchPosts: state.fetchPosts,
}))
)
if (loading) return <p>Memuat...</p>
if (error) return <p>Gagal: {error}</p>
return (
<div>
<button onClick={fetchPosts}>Muat ulang</button>
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
</div>
)
}
export default PostList
Kalau komponenmu re-render lebih sering dari dugaan, cek selector-nya dulu. Selector yang mengembalikan objek atau array baru setiap pemanggilan adalah penyebab paling umum. Aturannya: kembalikan primitif bila bisa, pakai useShallow bila harus mengembalikan objek.
Persist ke localStorage
State yang hilang setiap halaman di-refresh menyebalkan untuk keranjang belanja atau preferensi pengguna. Zustand punya middleware persist untuk itu:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useCartStore = create(
persist(
(set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (id) => set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
clear: () => set({ items: [] }),
}),
{ name: 'toko-ku-cart' }
)
)
Dengan nama toko-ku-cart, Zustand otomatis menyimpan state ke localStorage di bawah key itu dan memuatnya kembali saat aplikasi dibuka. Tidak ada kode manual JSON.stringify atau useEffect.
persist menyimpan seluruh state secara default. Kalau ada field yang tidak ingin ikut tersimpan — misalnya error — tambahkan partialize: (state) => ({ items: state.items }) di opsi kedua.
Debug dengan DevTools
Bungkus store dengan middleware devtools dan kamu bisa melihat setiap perubahan state di ekstensi Redux DevTools browser:
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
export const useCounterStore = create(
devtools(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }), false, 'increment'),
}),
{ name: 'CounterStore' }
)
)
Argumen ketiga di set adalah label aksi, supaya history di DevTools mudah dibaca. Ini opsional, tapi sangat membantu saat melacak bug state yang sulit direproduksi.
Menguji Store dengan Vitest
Karena store adalah fungsi murni di luar React, mengujinya tidak butuh render komponen. Instal vitest lalu tulis pengujian ini:
npm install -D vitest
import { describe, it, expect, beforeEach } from 'vitest'
import { useCounterStore } from './store'
beforeEach(() => {
useCounterStore.setState({ count: 0 })
})
describe('useCounterStore', () => {
it('menambah count', () => {
useCounterStore.getState().increment()
useCounterStore.getState().increment()
expect(useCounterStore.getState().count).toBe(2)
})
it('reset mengembalikan ke 0', () => {
useCounterStore.setState({ count: 5 })
useCounterStore.getState().reset()
expect(useCounterStore.getState().count).toBe(0)
})
})
getState() dan setState() adalah dua pintu untuk membaca dan menulis store dari luar React. Kalau kamu belum pernah menulis pengujian JavaScript, mulai dari panduan Vitest.
Zustand atau Redux, pilih yang mana?
Untuk proyek kecil sampai menengah, Zustand hampir selalu menang dari sisi kecepatan setup dan ukuran bundle. Redux Toolkit masih relevan untuk tim besar yang butuh pola ketat dan ekosistem middleware yang luas. Kalau kamu baru mulai, ambil Zustand dulu — migrasi nanti juga tidak sulit.
Apakah Zustand bisa dipakai dengan TypeScript?
Bisa, dan dukungan inferensi tipenya bagus. Store dideklarasikan dengan interface untuk state dan actions, lalu create<T>() memakai type parameter. Kalau kamu masih menulis JavaScript murni, transisi ke TypeScript bisa dilakukan bertahap per file.
Langkah berikutnya yang paling cepat terasa manfaatnya: ambil satu Context di proyekmu yang paling sering memicu re-render, pindahkan isinya ke satu store Zustand, lalu bandingkan jumlah re-render lewat React DevTools Profiler. Kalau hasilnya membaik, pindahkan sisanya satu per satu.