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:

bash
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:

javascript
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:

javascript
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:

javascript
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:

javascript
// 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:

javascript
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:

javascript
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:

javascript
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:

bash
npm install -D vitest
javascript
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.