Langsung ke konten
KamusNgoding
Mahir React 4 menit baca

Panduan Lengkap State Management Lanjutan di React dengan Zustand

#react #zustand #state management #advanced

Panduan Lengkap State Management Lanjutan di React dengan Zustand

Pendahuluan

Saat aplikasi React semakin besar, mengelola state menjadi tantangan nyata. Mungkin kamu sudah familiar dengan Context API atau Redux, namun keduanya memiliki trade-off tersendiri — Context API rentan terhadap re-render berlebihan, sementara Redux membutuhkan boilerplate yang cukup banyak.

Zustand hadir sebagai solusi di antara keduanya: ringan, fleksibel, dan tidak memerlukan provider yang membungkus seluruh aplikasi. Di artikel ini, kita akan membahas penggunaan Zustand secara lanjutan — bukan sekadar useState versi global, melainkan bagaimana membangun arsitektur state yang skalabel, performatif, dan mudah di-debug.

Jika kamu belum familiar dengan konsep dasar optimasi render di React, ada baiknya baca dulu Memahami Optimasi Render di React dengan useMemo dan useCallback sebelum melanjutkan.


Konsep Inti dan Pengaturan Lanjutan Zustand

Instalasi dan Struktur Store Dasar

npm install zustand

Store Zustand pada dasarnya adalah sebuah custom hook. Bedanya, state di dalamnya bersifat shared di seluruh aplikasi tanpa perlu Provider.

// src/stores/useAppStore.ts
import { create } from 'zustand';

interface AppState {
  count: number;
  user: { id: string; name: string } | null;
  increment: () => void;
  setUser: (user: { id: string; name: string }) => void;
  reset: () => void;
}

const initialState = {
  count: 0,
  user: null,
};

export const useAppStore = create<AppState>((set) => ({
  ...initialState,
  increment: () => set((state) => ({ count: state.count + 1 })),
  setUser: (user) => set({ user }),
  reset: () => set(initialState),
}));

Penggunaan Store di Komponen React

Setelah store dibuat, cara menggunakannya di komponen sangat sederhana — cukup panggil hook-nya dengan selector:

// src/components/Counter.tsx
import { useAppStore } from '../stores/useAppStore';

const Counter = () => {
  // Selector spesifik — hanya re-render saat `count` berubah
  const count = useAppStore((state) => state.count);
  const increment = useAppStore((state) => state.increment);
  const reset = useAppStore((state) => state.reset);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Tambah</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
};

export default Counter;
// src/components/UserProfile.tsx
import { useAppStore } from '../stores/useAppStore';

const UserProfile = () => {
  const user = useAppStore((state) => state.user);
  const setUser = useAppStore((state) => state.setUser);

  if (!user) {
    return (
      <button onClick={() => setUser({ id: '1', name: 'Budi' })}>
        Login sebagai Budi
      </button>
    );
  }

  return <p>Selamat datang, {user.name}!</p>;
};

export default UserProfile;

Slice Pattern untuk Store Besar

Untuk aplikasi berskala besar — bayangkan kamu membangun dashboard seller seperti yang ada di marketplace besar — memisahkan store ke dalam “slice” adalah pendekatan terbaik.

// src/stores/slices/cartSlice.ts
import { StateCreator } from 'zustand';

export interface CartItem {
  id: string;
  name: string;
  qty: number;
  price: number;
}

export interface CartSlice {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  clearCart: () => void;
  totalPrice: () => number;
}

export const createCartSlice: StateCreator<CartSlice> = (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, qty: i.qty + item.qty } : i
          ),
        };
      }
      return { items: [...state.items, item] };
    }),
  removeItem: (id) =>
    set((state) => ({
      items: state.items.filter((i) => i.id !== id),
    })),
  clearCart: () => set({ items: [] }),
  totalPrice: () =>
    get().items.reduce((sum, item) => sum + item.price * item.qty, 0),
});
// src/stores/slices/userSlice.ts
import { StateCreator } from 'zustand';

export interface UserSlice {
  userId: string | null;
  username: string | null;
  isLoggedIn: boolean;
  login: (id: string, name: string) => void;
  logout: () => void;
}

export const createUserSlice: StateCreator<UserSlice> = (set) => ({
  userId: null,
  username: null,
  isLoggedIn: false,
  login: (id, name) => set({ userId: id, username: name, isLoggedIn: true }),
  logout: () => set({ userId: null, username: null, isLoggedIn: false }),
});
// src/stores/useRootStore.ts
import { create } from 'zustand';
import { CartSlice, createCartSlice } from './slices/cartSlice';
import { UserSlice, createUserSlice } from './slices/userSlice';

type RootStore = CartSlice & UserSlice;

export const useRootStore = create<RootStore>()((...args) => ({
  ...createCartSlice(...args),
  ...createUserSlice(...args),
}));
// src/components/CartSummary.tsx — contoh penggunaan RootStore di komponen
import { useRootStore } from '../stores/useRootStore';

const CartSummary = () => {
  const items = useRootStore((state) => state.items);
  const removeItem = useRootStore((state) => state.removeItem);
  const totalPrice = useRootStore((state) => state.totalPrice);
  const username = useRootStore((state) => state.username);

  return (
    <div>
      <h2>Keranjang {username ?? 'Tamu'}</h2>
      {items.length === 0 ? (
        <p>Keranjang kosong</p>
      ) : (
        <>
          <ul>
            {items.map((item) => (
              <li key={item.id}>
                {item.name} x{item.qty} — Rp {item.price.toLocaleString('id-ID')}
                <button onClick={() => removeItem(item.id)}>Hapus</button>
              </li>
            ))}
          </ul>
          <p>
            <strong>Total: Rp {totalPrice().toLocaleString('id-ID')}</strong>
          </p>
        </>
      )}
    </div>
  );
};

export default CartSummary;

Manipulasi State Tingkat Lanjut dan Middleware

Middleware persist — State yang Bertahan Setelah Refresh

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

interface ThemeStore {
  theme: 'dark' | 'light';
  toggleTheme: () => void;
}

export const useThemeStore = create<ThemeStore>()(
  persist(
    (set) => ({
      theme: 'dark',
      toggleTheme: () =>
        set((state) => ({ theme: state.theme === 'dark' ? 'light' : 'dark' })),
    }),
    {
      name: 'theme-storage',
      storage: createJSONStorage(() => sessionStorage),
    }
  )
);

Middleware immer — Mutasi State yang Aman

Untuk state bersarang dalam (deeply nested), menulis logika immutable bisa menjadi rumit. Middleware immer memungkinkan kamu “memutasi” state secara langsung tanpa melanggar prinsip immutability.

npm install immer
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

interface TodoState {
  todos: { id: string; text: string; done: boolean }[];
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
  updateText: (id: string, text: string) => void;
}

export const useTodoStore = create<TodoState>()(
  immer((set) => ({
    todos: [],
    addTodo: (text) =>
      set((state) => {
        state.todos.push({
          id: crypto.randomUUID(),
          text,
          done: false,
        });
      }),
    toggleTodo: (id) =>
      set((state) => {
        const todo = state.todos.find((t) => t.id === id);
        if (todo) todo.done = !todo.done; // mutasi langsung — aman dengan immer
      }),
    updateText: (id, text) =>
      set((state) => {
        const todo = state.todos.find((t) => t.id === id);
        if (todo) todo.text = text;
      }),
  }))
);

Middleware devtools untuk Debugging

import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
}

export const useCounterStore = create<CounterState>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () => set((s) => ({ count: s.count + 1 }), false, 'increment'),
      decrement: () => set((s) => ({ count: s.count - 1 }), false, 'decrement'),
    }),
    { name: 'CounterStore' }
  )
);

Dengan ini, setiap perubahan state akan muncul di Redux DevTools Extension dengan nama action yang jelas.


Optimasi Kinerja dan Pola-Pola Kompleks

Selector untuk Mencegah Re-render Berlebihan

Ini adalah poin paling krusial. Zustand hanya men-trigger re-render pada komponen yang benar-benar menggunakan state yang berubah.

// ❌ Buruk — komponen re-render setiap kali APA SAJA di store berubah
const { user, items, count } = useRootStore();

// ✅ Baik — hanya re-render saat field yang dipilih berubah
const items = useRootStore((state) => state.items);
const user = useRootStore((state) => state.user);

Untuk selector yang mengembalikan objek baru setiap render, gunakan useShallow:

import { useShallow } from 'zustand/react/shallow';

// Mengambil beberapa field sekaligus tanpa re-render berlebihan
const { name, email } = useUserStore(
  useShallow((state) => ({ name: state.user?.name, email: state.user?.email }))
);

Computed State dengan subscribeWithSelector

import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

interface CartItem {
  id: string;
  name: string;
  qty: number;
  price: number;
}

interface CartState {
  items: CartItem[];
  addItem: (item: CartItem) => void;
}

const useCartStore = create<CartState>()(
  subscribeWithSelector((set) => ({
    items: [],
    addItem: (item) => set((s) => ({ items: [...s.items, item] })),
  }))
);

// Subscribe ke perubahan jumlah item dari luar komponen
const unsubscribe = useCartStore.subscribe(
  (state) => state.items.length,
  (itemCount, prevCount) => {
    if (itemCount > prevCount) {
      console.log(`Item ditambahkan! Total: ${itemCount}`);
    }
  }
);

// Jangan lupa cleanup saat tidak dibutuhkan
// unsubscribe();

Akses Store di Luar Komponen (Service / Utility)

// Mengambil state atau memanggil action dari luar React tree
const addItem = useCartStore.getState().addItem;
addItem({ id: '1', name: 'Produk A', qty: 1, price: 50000 });

// Atau subscribe manual untuk sinkronisasi dengan sistem lain
useCartStore.subscribe((state) => {
  analytics.track('cart_updated', { itemCount: state.items.length });
});

Contoh Kasus Nyata: Notifikasi Real-Time

Berikut implementasi store untuk fitur notifikasi real-time — konsep yang umum dipakai pada aplikasi seperti platform ojek online atau e-commerce:

// src/stores/useNotificationStore.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { persist } from 'zustand/middleware';

interface Notification {
  id: string;
  message: string;
  type: 'info' | 'success' | 'warning' | 'error';
  read: boolean;
  createdAt: number;
}

interface NotificationStore {
  notifications: Notification[];
  unreadCount: number;
  push: (notif: Omit<Notification, 'id' | 'read' | 'createdAt'>) => void;
  markAsRead: (id: string) => void;
  markAllRead: () => void;
  remove: (id: string) => void;
}

export const useNotificationStore = create<NotificationStore>()(
  persist(
    immer((set) => ({
      notifications: [],
      unreadCount: 0,
      push: (notif) =>
        set((state) => {
          state.notifications.unshift({
            ...notif,
            id: crypto.randomUUID(),
            read: false,
            createdAt: Date.now(),
          });
          state.unreadCount += 1;
        }),
      markAsRead: (id) =>
        set((state) => {
          const n = state.notifications.find((n) => n.id === id);
          if (n && !n.read) {
            n.read = true;
            state.unreadCount = Math.max(0, state.unreadCount - 1);
          }
        }),
      markAllRead: () =>
        set((state) => {
          state.notifications.forEach((n) => (n.read = true));
          state.unreadCount = 0;
        }),
      remove: (id) =>
        set((state) => {
          const idx = state.notifications.findIndex((n) => n.id === id);
          if (idx !== -1) {
            if (!state.notifications[idx].read) state.unreadCount -= 1;
            state.notifications.splice(idx, 1);
          }
        }),
    })),
    { name: 'notifications' }
  )
);
// src/components/NotificationBell.tsx
import { useNotificationStore } from '../stores/useNotificationStore';

const NotificationBell = () => {
  const unreadCount = useNotificationStore((s) => s.unreadCount);
  const notifications = useNotificationStore((s) => s.notifications);
  const markAsRead = useNotificationStore((s) => s.markAsRead);
  const markAllRead = useNotificationStore((s) => s.markAllRead);
  const remove = useNotificationStore((s) => s.remove);

  return (
    <div>
      <button onClick={markAllRead}>
        Notifikasi {unreadCount > 0 && <span>{unreadCount}</span>}
      </button>
      <ul>
        {notifications.map((notif) => (
          <li key={notif.id} style={{ opacity: notif.read ? 0.5 : 1 }}>
            <span>[{notif.type.toUpperCase()}] {notif.message}</span>
            {!notif.read && (
              <button onClick={() => markAsRead(notif.id)}>Tandai dibaca</button>
            )}
            <button onClick={() => remove(notif.id)}>Hapus</button>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default NotificationBell;

Troubleshooting: Error yang Sering Muncul

Komponen Terus Re-render Meski State Tidak Berubah

Penyebab: Selector mengembalikan objek atau array baru setiap kali dipanggil, sehingga Zustand menganggap state selalu berubah karena referensi berbeda.

Solusi:

import { useShallow } from 'zustand/react/shallow';

// ❌ Menyebabkan re-render terus karena { a, b } selalu objek baru
const data = useStore((s) => ({ a: s.a, b: s.b }));

// ✅ useShallow melakukan shallow comparison sehingga re-render hanya terjadi
//    saat nilai a atau b benar-benar berubah
const data = useStore(useShallow((s) => ({ a: s.a, b: s.b })));

State Tidak Persisten Setelah Halaman Direfresh

Penyebab: Middleware persist tidak dikonfigurasi, atau nama storage key konflik dengan store lain di aplikasi yang sama.

Solusi:

import { persist, createJSONStorage } from 'zustand/middleware';

export const useStore = create<MyState>()(
  persist(
    (set) => ({
      theme: 'dark',
      token: null,
      // action lainnya...
    }),
    {
      name: 'my-unique-store-key', // pastikan nama unik per store
      storage: createJSONStorage(() => localStorage),
      // Partial persist — hanya simpan field tertentu agar lebih aman
      partialize: (state) => ({ theme: state.theme, token: state.token }),
    }
  )
);

Immer Middleware Tidak Bekerja dengan Middleware Lain

Penyebab: Urutan penggabungan middleware yang salah saat menggunakan immer bersama persist atau devtools.

Solusi:

// ✅ Urutan yang benar: devtools → persist → immer (dari luar ke dalam)
export const useStore = create<State>()(
  devtools(
    persist(
      immer((set) => ({
        // state dan action di sini
      })),
      { name: 'store-key' }
    ),
    { name: 'MyStore' }
  )
);

Action Tidak Tersedia saat Dipanggil dari Event Listener

Penyebab: Memanggil action dari dalam closure yang sudah stale — nilai lama terkaptured saat komponen pertama kali render.

Solusi:

// ❌ Closure stale — addMessage bisa jadi sudah tidak valid
useEffect(() => {
  socket.on('message', (data) => {
    addMessage(data);
  });
}, []); // dependency array kosong

// ✅ Ambil action langsung dari store — selalu mengambil versi terbaru
useEffect(() => {
  socket.on('message', (data) => {
    useMessageStore.getState().addMessage(data);
  });

  return () => socket.off('message');
}, []);

Pertanyaan yang Sering Diajukan

Apa perbedaan Zustand dengan Redux Toolkit?

Zustand jauh lebih minimalis — tidak ada reducer, action type, atau dispatch. Kamu menulis fungsi langsung di dalam store tanpa ceremony tambahan. Redux Toolkit lebih terstruktur dan cocok untuk tim besar dengan kebutuhan trace action yang ketat. Untuk proyek menengah ke atas yang butuh fleksibilitas tanpa boilerplate berlebihan, Zustand adalah pilihan yang sangat kompetitif. Kamu bisa membandingkan lebih lanjut di artikel React Context vs Redux: Perbedaan dan Kapan Menggunakannya.

Bagaimana cara menangani state async (misalnya fetch data) di Zustand?

Zustand tidak memiliki middleware khusus untuk async — kamu cukup menulis fungsi async langsung di dalam action seperti biasa:

interface UserState {
  user: { id: string; name: string } | null;
  loading: boolean;
  error: string | null;
  fetchUser: (id: string) => Promise<void>;
}

export const useUserStore = create<UserState>()((set) => ({
  user: null,
  loading: false,
  error: null,
  fetchUser: async (id: string) => {
    set({ loading: true, error: null });
    try {
      const res = await fetch(`/api/users/${id}`);
      const user = await res.json();
      set({ user, loading: false });
    } catch {
      set({ error: 'Gagal memuat data pengguna', loading: false });
    }
  },
}));

Apakah Zustand cocok digunakan bersama React Query atau SWR?

Ya, justru kombinasi ini sangat dianjurkan. Gunakan React Query atau SWR untuk server state (data dari API, caching, refetching otomatis), dan Zustand untuk client state (UI state, preferensi pengguna, shopping cart). Hindari menyimpan data server ke dalam Zustand karena akan menciptakan dua sumber kebenaran yang bisa saling bertentangan dan mempersulit sinkronisasi.

Berapa besar bundle size Zustand?

Zustand sangat ringan — hanya sekitar 1kB (gzip). Ini jauh lebih kecil dibanding Redux (~2.9kB) ditambah React-Redux (~5.4kB). Untuk aplikasi yang memprioritaskan performa loading awal, keunggulan ukuran ini cukup signifikan terutama pada koneksi lambat.

Bagaimana cara testing store Zustand?

// __tests__/cartStore.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCartStore } from '../stores/useCartStore';

beforeEach(() => {
  useCartStore.setState({ items: [] }); // reset state sebelum tiap test
});

test('addItem menambahkan item baru ke cart', () => {
  const { result } = renderHook(() => useCartStore());
  act(() => {
    result.current.addItem({ id: '1', name: 'Test', qty: 1, price: 10000 });
  });
  expect(result.current.items).toHaveLength(1);
  expect(result.current.items[0].name).toBe('Test');
});

test('removeItem menghapus item dari cart', () => {
  const { result } = renderHook(() => useCartStore());
  act(() => {
    result.current.addItem({ id: '1', name: 'Test', qty: 1, price: 10000 });
    result.current.removeItem('1');
  });
  expect(result.current.items).toHaveLength(0);
});

test('totalPrice menghitung harga total dengan benar', () => {
  const { result } = renderHook(() => useCartStore());
  act(() => {
    result.current.addItem({ id: '1', name: 'A', qty: 2, price: 15000 });
    result.current.addItem({ id: '2', name: 'B', qty: 1, price: 20000 });
  });
  expect(result.current.totalPrice()).toBe(50000);
});

Kesimpulan

Zustand menawarkan pendekatan state management yang pragmatis: ringan, fleksibel, dan tidak memerlukan provider global. Dengan slice pattern, middleware seperti persist dan immer, serta teknik selector yang tepat, kamu bisa membangun arsitektur state yang skalabel dan performatif di proyek React apapun.

Kunci utamanya adalah: gunakan selector spesifik, pisahkan server state dari client state, dan manfaatkan middleware sesuai kebutuhan. Selamat belajar dan terus bereksperimen — menguasai Zustand adalah investasi yang sangat berharga untuk perjalananmu sebagai React developer, dan KamusNgoding selalu ada untuk menemanimu belajar!

Artikel Terkait