Tutorial Zustand: State Management Ringan untuk React - Nayaka Yoga Pradipta
ID | EN

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?

AspekReduxZustand
SetupStore + Reducers + Actions + ProviderSingle create() function
BoilerplateBanyak file dan constantMinimal, bisa 1 file
ProviderWajib wrap dengan ProviderTidak perlu Provider
Learning CurveSteepSangat mudah
Bundle Size~7KB (Redux + React-Redux)~1KB
DevToolsButuh middlewareBuilt-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 type
  • set - Function untuk update state
  • (state) => ({ count: state.count + 1 }) - Update berdasarkan state sebelumnya
  • set({ 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

FiturZustandRedux ToolkitJotai
Bundle Size~1KB~7KB~3KB
BoilerplateMinimalMediumMinimal
Learning CurveEasyMediumEasy
DevTools
Persist✅ Middleware✅ Middleware✅ Atoms
AsyncNativeRTK Query / ThunkNative
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! 🐻