Tutorial Zustand: State Management Ringan untuk React
Selasa, 23 Des 2025
Kalau kamu pernah pakai Redux, pasti tahu betapa ribetnya setup: reducers, actions, action types, middleware… Belum lagi boilerplate yang harus ditulis berulang-ulang.
Zustand hadir sebagai alternatif yang jauh lebih simple. Nama “Zustand” berasal dari bahasa Jerman yang artinya “state”. Dan sesuai namanya, library ini fokus ke satu hal: state management yang straightforward.
Kenapa Butuh State Management?
Sebelum bahas Zustand, mari kita pahami dulu kenapa state management itu penting.
Di React, passing data antar component pakai props. Tapi kalau aplikasi makin kompleks, kamu bakal ketemu masalah prop drilling:
// Prop drilling nightmare 😱
function App() {
const [user, setUser] = useState(null);
return <Layout user={user} setUser={setUser} />;
}
function Layout({ user, setUser }) {
return <Sidebar user={user} setUser={setUser} />;
}
function Sidebar({ user, setUser }) {
return <UserProfile user={user} setUser={setUser} />;
}
function UserProfile({ user, setUser }) {
// Akhirnya sampai juga...
return <div>{user?.name}</div>;
}
State management library membantu kamu share state ke component manapun tanpa passing props terus-menerus.
Apa itu Zustand?
Zustand adalah state management library yang minimalis tapi powerful. Dibuat oleh tim yang sama dengan Jotai dan React Spring (Poimandres).
// Zustand store dalam 5 baris
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
Itu saja. Tidak ada Provider, tidak ada reducer, tidak ada action type constants.
Kenapa Zustand Lebih Simple dari Redux?
| Aspek | Redux | Zustand |
|---|---|---|
| Setup | Store + Reducers + Actions + Provider | Single create() function |
| Boilerplate | Banyak file dan constant | Minimal, bisa 1 file |
| Provider | Wajib wrap dengan Provider | Tidak perlu Provider |
| Learning Curve | Steep | Sangat mudah |
| Bundle Size | ~7KB (Redux + React-Redux) | ~1KB |
| DevTools | Butuh middleware | Built-in support |
Instalasi dan Setup
1. Install Package
npm install zustand
# atau
pnpm add zustand
# atau
yarn add zustand
Selesai. Tidak ada setup tambahan. Tidak perlu wrap aplikasi dengan Provider.
2. Struktur Project yang Disarankan
src/
├── stores/
│ ├── useUserStore.ts
│ ├── useCartStore.ts
│ └── useThemeStore.ts
├── components/
└── ...
Membuat Store Pertama
Mari buat counter store sederhana:
// filepath: /src/stores/useCounterStore.ts
import { create } from 'zustand';
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
Penjelasan Kode
create<CounterState>- Membuat store dengan TypeScript typeset- Function untuk update state(state) => ({ count: state.count + 1 })- Update berdasarkan state sebelumnyaset({ count: 0 })- Update langsung tanpa reference ke state sebelumnya
Menggunakan Store di Components
// filepath: /src/components/Counter.tsx
import { useCounterStore } from '../stores/useCounterStore';
export function Counter() {
const { count, increment, decrement, reset } = useCounterStore();
return (
<div className="flex flex-col items-center gap-4">
<h1 className="text-4xl font-bold">{count}</h1>
<div className="flex gap-2">
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
<button onClick={increment}>+</button>
</div>
</div>
);
}
Selector untuk Optimasi Performa
Kalau kamu hanya butuh sebagian state, gunakan selector:
// ❌ Ini akan re-render setiap kali ANY state berubah
const state = useCounterStore();
// ✅ Ini hanya re-render kalau `count` berubah
const count = useCounterStore((state) => state.count);
// ✅ Ambil beberapa nilai sekaligus
const { count, increment } = useCounterStore((state) => ({
count: state.count,
increment: state.increment,
}));
Actions dan State Updates
Contoh Store yang Lebih Kompleks
// filepath: /src/stores/useTodoStore.ts
import { create } from 'zustand';
interface Todo {
id: string;
text: string;
completed: boolean;
}
interface TodoState {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
deleteTodo: (id: string) => void;
setFilter: (filter: 'all' | 'active' | 'completed') => void;
clearCompleted: () => void;
}
export const useTodoStore = create<TodoState>((set) => ({
todos: [],
filter: 'all',
addTodo: (text) =>
set((state) => ({
todos: [
...state.todos,
{ id: crypto.randomUUID(), text, completed: false },
],
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
),
})),
deleteTodo: (id) =>
set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id),
})),
setFilter: (filter) => set({ filter }),
clearCompleted: () =>
set((state) => ({
todos: state.todos.filter((todo) => !todo.completed),
})),
}));
Computed Values dengan get
// filepath: /src/stores/useTodoStore.ts
export const useTodoStore = create<TodoState>((set, get) => ({
todos: [],
filter: 'all',
// Getter untuk filtered todos
getFilteredTodos: () => {
const { todos, filter } = get();
switch (filter) {
case 'active':
return todos.filter((t) => !t.completed);
case 'completed':
return todos.filter((t) => t.completed);
default:
return todos;
}
},
// Getter untuk count
getActiveCount: () => {
return get().todos.filter((t) => !t.completed).length;
},
// ... actions lainnya
}));
Async Actions (API Calls)
Zustand mendukung async actions secara native tanpa middleware tambahan:
// filepath: /src/stores/useProductStore.ts
import { create } from 'zustand';
interface Product {
id: number;
name: string;
price: number;
}
interface ProductState {
products: Product[];
isLoading: boolean;
error: string | null;
fetchProducts: () => Promise<void>;
createProduct: (product: Omit<Product, 'id'>) => Promise<void>;
}
export const useProductStore = create<ProductState>((set, get) => ({
products: [],
isLoading: false,
error: null,
fetchProducts: async () => {
set({ isLoading: true, error: null });
try {
const res = await fetch('/api/products');
if (!res.ok) throw new Error('Failed to fetch');
const products = await res.json();
set({ products, isLoading: false });
} catch (error) {
set({ error: (error as Error).message, isLoading: false });
}
},
createProduct: async (product) => {
set({ isLoading: true, error: null });
try {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(product),
});
if (!res.ok) throw new Error('Failed to create');
const newProduct = await res.json();
set((state) => ({
products: [...state.products, newProduct],
isLoading: false,
}));
} catch (error) {
set({ error: (error as Error).message, isLoading: false });
}
},
}));
Menggunakan Async Store
// filepath: /src/components/ProductList.tsx
import { useEffect } from 'react';
import { useProductStore } from '../stores/useProductStore';
export function ProductList() {
const { products, isLoading, error, fetchProducts } = useProductStore();
useEffect(() => {
fetchProducts();
}, [fetchProducts]);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} - Rp {product.price.toLocaleString()}
</li>
))}
</ul>
);
}
Persist State ke localStorage
Zustand punya middleware persist untuk menyimpan state ke localStorage:
// filepath: /src/stores/useThemeStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface ThemeState {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: 'light',
toggleTheme: () =>
set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light',
})),
}),
{
name: 'theme-storage', // Key di localStorage
}
)
);
Persist Options Lengkap
// filepath: /src/stores/useUserStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface UserState {
user: { name: string; email: string } | null;
token: string | null;
login: (user: { name: string; email: string }, token: string) => void;
logout: () => void;
}
export const useUserStore = create<UserState>()(
persist(
(set) => ({
user: null,
token: null,
login: (user, token) => set({ user, token }),
logout: () => set({ user: null, token: null }),
}),
{
name: 'user-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
user: state.user,
token: state.token,
}),
}
)
);
DevTools Integration
Zustand mendukung Redux DevTools untuk debugging:
// filepath: /src/stores/useCounterStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
export const useCounterStore = create<CounterState>()(
devtools(
(set) => ({
count: 0,
increment: () =>
set(
(state) => ({ count: state.count + 1 }),
false,
'increment' // Action name di DevTools
),
decrement: () =>
set(
(state) => ({ count: state.count - 1 }),
false,
'decrement'
),
}),
{ name: 'CounterStore' }
)
);
Kombinasi Multiple Middleware
// filepath: /src/stores/useCartStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
clearCart: () => void;
getTotalPrice: () => number;
}
export const useCartStore = create<CartState>()(
devtools(
persist(
(set, get) => ({
items: [],
addItem: (item) =>
set(
(state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
},
false,
'addItem'
),
removeItem: (id) =>
set(
(state) => ({
items: state.items.filter((i) => i.id !== id),
}),
false,
'removeItem'
),
clearCart: () => set({ items: [] }, false, 'clearCart'),
getTotalPrice: () => {
return get().items.reduce(
(total, item) => total + item.price * item.quantity,
0
);
},
}),
{ name: 'cart-storage' }
),
{ name: 'CartStore' }
)
);
Zustand vs Redux vs Jotai
| Fitur | Zustand | Redux Toolkit | Jotai |
|---|---|---|---|
| Bundle Size | ~1KB | ~7KB | ~3KB |
| Boilerplate | Minimal | Medium | Minimal |
| Learning Curve | Easy | Medium | Easy |
| DevTools | ✅ | ✅ | ✅ |
| Persist | ✅ Middleware | ✅ Middleware | ✅ Atoms |
| Async | Native | RTK Query / Thunk | Native |
| Provider | ❌ Tidak perlu | ✅ Wajib | ✅ Opsional |
| Atom-based | ❌ | ❌ | ✅ |
| TypeScript | ✅ Excellent | ✅ Good | ✅ Excellent |
Kapan Pakai Zustand?
- Aplikasi medium-size dengan state yang straightforward
- Tim yang ingin escape Redux complexity
- Butuh setup cepat tanpa boilerplate
- State management untuk side projects atau MVP
Kapan Pakai Redux Toolkit?
- Enterprise aplikasi yang sudah pakai Redux
- Butuh RTK Query untuk data fetching
- Tim yang sudah familiar dengan Redux patterns
Kapan Pakai Jotai?
- Aplikasi dengan banyak independent state atoms
- Prefer bottom-up state composition
- Butuh fine-grained reactivity
Best Practices
1. Pisahkan Store Berdasarkan Domain
// ❌ Jangan gabung semua di satu store
const useStore = create((set) => ({
user: null,
cart: [],
theme: 'light',
notifications: [],
// ... terlalu banyak
}));
// ✅ Pisah per domain
const useUserStore = create((set) => ({ ... }));
const useCartStore = create((set) => ({ ... }));
const useThemeStore = create((set) => ({ ... }));
2. Gunakan Selector untuk Performa
// ❌ Component re-render setiap state berubah
function Component() {
const store = useStore();
return <div>{store.count}</div>;
}
// ✅ Hanya re-render saat count berubah
function Component() {
const count = useStore((state) => state.count);
return <div>{count}</div>;
}
3. Immer untuk Nested State
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface State {
user: {
profile: {
name: string;
settings: {
notifications: boolean;
};
};
};
updateNotificationSetting: (value: boolean) => void;
}
const useStore = create<State>()(
immer((set) => ({
user: {
profile: {
name: 'John',
settings: {
notifications: true,
},
},
},
updateNotificationSetting: (value) =>
set((state) => {
state.user.profile.settings.notifications = value;
}),
}))
);
4. Testing Store
// filepath: /src/stores/__tests__/useCounterStore.test.ts
import { useCounterStore } from '../useCounterStore';
describe('useCounterStore', () => {
beforeEach(() => {
useCounterStore.setState({ count: 0 });
});
it('should increment count', () => {
useCounterStore.getState().increment();
expect(useCounterStore.getState().count).toBe(1);
});
it('should decrement count', () => {
useCounterStore.setState({ count: 5 });
useCounterStore.getState().decrement();
expect(useCounterStore.getState().count).toBe(4);
});
it('should reset count', () => {
useCounterStore.setState({ count: 10 });
useCounterStore.getState().reset();
expect(useCounterStore.getState().count).toBe(0);
});
});
5. Subscribe ke State Changes
// Subscribe di luar React
const unsubscribe = useStore.subscribe(
(state) => state.count,
(count, previousCount) => {
console.log('Count changed from', previousCount, 'to', count);
}
);
// Cleanup
unsubscribe();
Kesimpulan
Zustand adalah state management library yang refreshing di ekosistem React. Dengan API yang minimal tapi powerful, kamu bisa manage state tanpa harus berurusan dengan boilerplate yang berlebihan.
Key takeaways:
- Simple API: Cukup
create()dan langsung bisa dipakai - No Provider: Tidak perlu wrap aplikasi
- TypeScript First: Type inference yang excellent
- Middleware: Persist, DevTools, Immer built-in
- Lightweight: Hanya ~1KB gzipped
Kalau kamu masih pakai Redux dan merasa overwhelmed dengan boilerplate-nya, give Zustand a try. Kamu mungkin akan surprised betapa mudahnya state management bisa dilakukan.
Happy coding! 🐻