Use Cases (Diperbarui: 2/6/2026)

State management Zustand dengan Claude Code

Rancang store Zustand dengan Claude Code: selector, persist parsial, async action, test, dan prompt review.

State management Zustand dengan Claude Code

Tentukan batas state terlebih dahulu

Zustand adalah library state management yang ringan untuk React. State management bukan berarti memasukkan semua variabel ke objek global. Artinya adalah menentukan nilai mana yang perlu dipakai banyak komponen, action mana yang boleh mengubahnya, dan nilai mana yang cukup menjadi local state di komponen. Claude Code bisa membuat store dengan cepat, tetapi perlu batas yang jelas. Tanpa batas, teks sementara form, data autentikasi, respons API, toast, dan cart bisa bercampur dalam satu global store.

Panduan ini memakai Claude Code sebagai asisten desain state, bukan hanya generator kode. Use case yang dibahas adalah filter admin dashboard, cart, state UI autentikasi, modal/toast, dan optimistic update. Contohnya mencakup TypeScript store, selector, persist dengan partialize, async action, test Vitest, dan prompt review yang bisa langsung disalin.

Rujukan teknisnya adalah Zustand official introduction, dokumentasi persist middleware, dan panduan useShallow. Untuk memisahkan server state, baca panduan TanStack Query. Untuk state yang lebih atomik, bandingkan dengan artikel Jotai atoms.

State apa yang masuk ke Zustand

Aturan praktisnya sederhana: gunakan Zustand ketika beberapa komponen yang berjauhan membutuhkan nilai yang sama, ketika logika update ingin dites di satu tempat, atau ketika URL tidak cukup menjelaskan state UI. Jangan menyimpan semua respons server di Zustand. Daftar produk, daftar pengguna, dan hasil pencarian biasanya membutuhkan cache, refetch, stale time, dan pemulihan error; itu lebih cocok untuk server-state library.

Use caseMasuk ZustandTidak masukInstruksi untuk Claude Code
Filter adminkeyword, status, page, pageSizerespons API lengkapPisahkan nilai URL dan UI
CartSKU, quantity, display pricepayment session, stok asliPersist hanya field rendah risiko
Auth UIlogin dialog, checking statetoken, email, alamatJangan simpan PII atau secret
Modal/toastactiveModal, toast queue pendekaudit log, error panjangSimpan hanya yang dirender UI
Optimistic updaterequestId, snapshot sebelumnyakebenaran final serverTentukan rollback dan race rule

Masa pernah mengalami masalah di admin screen: targetnya “pakai Zustand”, bukan membuat batas state. Versi awal menyimpan input pencarian, query URL, rows dari API, selection, dan toast dalam satu store. Setelah navigasi, filter lama masih memengaruhi layar baru. Solusinya bukan middleware baru, tetapi inventaris state yang aman untuk tetap global.

flowchart LR
  UI[React components] --> Selectors[Selectors and useShallow]
  Selectors --> Store[Zustand store]
  Store --> Actions[Actions]
  Actions --> API[API and server state]
  Store --> Persist[persist partialize]
  Persist --> Storage[localStorage safe fields]

TypeScript store

Store berikut sengaja dibatasi: filter admin, cart, auth UI, modal, dan toasts. Di aplikasi besar file bisa dipisah, tetapi meminta Claude Code membuat slice lengkap lebih mudah direview.

import { create, type StateCreator } from "zustand";

export type OrderStatus = "all" | "paid" | "refunded" | "failed";
export type AuthUiStatus = "anonymous" | "checking" | "signedIn";
export type ModalId = "invite-user" | "cart-drawer" | "delete-order" | null;

export interface AdminFilters {
  keyword: string;
  status: OrderStatus;
  page: number;
  pageSize: number;
}

export interface CartLine {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

export interface AuthUi {
  status: AuthUiStatus;
  loginDialogOpen: boolean;
}

export interface Toast {
  id: string;
  kind: "success" | "error" | "info";
  message: string;
}

export interface CommerceUiState {
  filters: AdminFilters;
  cart: CartLine[];
  auth: AuthUi;
  activeModal: ModalId;
  toasts: Toast[];
  setFilters: (patch: Partial<AdminFilters>) => void;
  resetFilters: () => void;
  addToCart: (line: Omit<CartLine, "quantity">) => void;
  removeFromCart: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  setAuthStatus: (status: AuthUiStatus) => void;
  setLoginDialogOpen: (open: boolean) => void;
  openModal: (modal: Exclude<ModalId, null>) => void;
  closeModal: () => void;
  pushToast: (toast: Omit<Toast, "id">) => void;
  dismissToast: (id: string) => void;
  checkoutTotal: () => number;
}

export const initialFilters: AdminFilters = {
  keyword: "",
  status: "all",
  page: 1,
  pageSize: 25,
};

const createToastId = () =>
  globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);

export const createCommerceUiSlice: StateCreator<CommerceUiState> = (set, get) => ({
  filters: initialFilters,
  cart: [],
  auth: { status: "anonymous", loginDialogOpen: false },
  activeModal: null,
  toasts: [],

  setFilters: (patch) =>
    set((state) => ({
      filters: {
        ...state.filters,
        ...patch,
        page: patch.page ?? 1,
      },
    })),

  resetFilters: () => set({ filters: initialFilters }),

  addToCart: (line) =>
    set((state) => {
      const current = state.cart.find((item) => item.id === line.id);
      if (!current) {
        return { cart: [...state.cart, { ...line, quantity: 1 }] };
      }

      return {
        cart: state.cart.map((item) =>
          item.id === line.id ? { ...item, quantity: item.quantity + 1 } : item,
        ),
      };
    }),

  removeFromCart: (id) =>
    set((state) => ({
      cart: state.cart.filter((item) => item.id !== id),
    })),

  updateQuantity: (id, quantity) =>
    set((state) => ({
      cart: state.cart
        .map((item) =>
          item.id === id ? { ...item, quantity: Math.max(0, quantity) } : item,
        )
        .filter((item) => item.quantity > 0),
    })),

  setAuthStatus: (status) =>
    set((state) => ({
      auth: { ...state.auth, status },
    })),

  setLoginDialogOpen: (open) =>
    set((state) => ({
      auth: { ...state.auth, loginDialogOpen: open },
    })),

  openModal: (modal) => set({ activeModal: modal }),
  closeModal: () => set({ activeModal: null }),

  pushToast: (toast) =>
    set((state) => ({
      toasts: [...state.toasts, { ...toast, id: createToastId() }],
    })),

  dismissToast: (id) =>
    set((state) => ({
      toasts: state.toasts.filter((toast) => toast.id !== id),
    })),

  checkoutTotal: () =>
    get().cart.reduce((total, item) => total + item.price * item.quantity, 0),
});

export const useCommerceUiStore = create<CommerceUiState>()(createCommerceUiSlice);

Hal pentingnya: store menyimpan state UI autentikasi, bukan secret autentikasi. Access token, email, alamat, nama legal, dan payment session tidak boleh ada di sini. Saat meminta Claude Code menambahkan persist, jelaskan bahwa PII, yaitu informasi yang bisa mengidentifikasi orang, tidak boleh disimpan.

Selector untuk mengurangi rerender

Komponen yang memanggil useCommerceUiStore() tanpa selector akan subscribe ke seluruh store. Akibatnya toast baru bisa membuat badge cart rerender. Selector mengambil hanya bagian yang dibutuhkan. Jika selector mengembalikan object, useShallow membantu menghindari update yang tidak perlu.

import { useShallow } from "zustand/react/shallow";
import {
  useCommerceUiStore,
  type CommerceUiState,
} from "./commerce-ui-store";

export const selectCartCount = (state: CommerceUiState) =>
  state.cart.reduce((sum, item) => sum + item.quantity, 0);

export const selectCartTotal = (state: CommerceUiState) => state.checkoutTotal();

export function CartBadge() {
  const count = useCommerceUiStore(selectCartCount);

  return <button type="button">Cart ({count})</button>;
}

export function AdminFilterSummary() {
  const { filters, setFilters, resetFilters } = useCommerceUiStore(
    useShallow((state) => ({
      filters: state.filters,
      setFilters: state.setFilters,
      resetFilters: state.resetFilters,
    })),
  );

  return (
    <form>
      <input
        value={filters.keyword}
        placeholder="Search orders"
        onChange={(event) => setFilters({ keyword: event.currentTarget.value })}
      />
      <select
        value={filters.status}
        onChange={(event) =>
          setFilters({ status: event.currentTarget.value as CommerceUiState["filters"]["status"] })
        }
      >
        <option value="all">All</option>
        <option value="paid">Paid</option>
        <option value="refunded">Refunded</option>
        <option value="failed">Failed</option>
      </select>
      <output>Page {filters.page}</output>
      <button type="button" onClick={resetFilters}>
        Reset
      </button>
    </form>
  );
}

Minta Claude Code mengecek semua komponen yang mengimpor store. Selain komponen debug, tidak ada yang boleh subscribe ke store penuh. Aturan ini penting saat tabel, filter, sidebar, dan notifikasi berubah bersamaan.

Persist hanya field aman

persist menjaga state setelah reload, tetapi bisa membocorkan data. Cart dan filter masuk akal untuk disimpan; modal, toast, requestId, token, dan PII tidak. Gunakan partialize untuk membatasi field yang disimpan.

import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
  createCommerceUiSlice,
  type CommerceUiState,
} from "./commerce-ui-store";

const noopStorage = {
  getItem: () => null,
  setItem: () => undefined,
  removeItem: () => undefined,
};

type PersistedCommerceUiState = Pick<CommerceUiState, "filters" | "cart">;

export const usePersistedCommerceUiStore = create<CommerceUiState>()(
  persist(createCommerceUiSlice, {
    name: "commerce-ui-v1",
    version: 1,
    storage: createJSONStorage(() =>
      typeof window === "undefined" ? noopStorage : window.localStorage,
    ),
    partialize: (state): PersistedCommerceUiState => ({
      filters: state.filters,
      cart: state.cart,
    }),
  }),
);

Pada SSR, server tidak bisa membaca localStorage. Jika HTML awal menampilkan cart 0 lalu browser memulihkan 3 item, bisa terjadi hydration mismatch. Tampilkan nilai seperti itu setelah mount atau pisahkan dari markup kritis.

Async action dan optimistic update

Optimistic update mengubah UI sebelum server mengonfirmasi. Cocok untuk follow, like, atau perubahan quantity cart yang risikonya rendah. Bahayanya adalah response lama datang setelah response baru. Simpan requestId dan snapshot sebelumnya untuk rollback.

import { create } from "zustand";

interface Profile {
  id: string;
  name: string;
  isFollowing: boolean;
}

interface ProfileState {
  profiles: Record<string, Profile>;
  followRequestIds: Record<string, string>;
  setProfile: (profile: Profile) => void;
  followOptimistically: (profileId: string) => Promise<void>;
}

const removeKey = <T,>(record: Record<string, T>, key: string) => {
  const { [key]: _removed, ...rest } = record;
  return rest;
};

async function updateFollowOnServer(profileId: string, follow: boolean) {
  const response = await fetch(`/api/profiles/${profileId}/follow`, {
    method: follow ? "PUT" : "DELETE",
  });

  if (!response.ok) {
    throw new Error(`Follow update failed: ${response.status}`);
  }
}

export const useProfileStore = create<ProfileState>((set, get) => ({
  profiles: {},
  followRequestIds: {},

  setProfile: (profile) =>
    set((state) => ({
      profiles: { ...state.profiles, [profile.id]: profile },
    })),

  followOptimistically: async (profileId) => {
    const before = get().profiles[profileId];
    if (!before) {
      throw new Error("Profile not found");
    }

    const requestId = `${profileId}-${Date.now()}`;

    set((state) => ({
      profiles: {
        ...state.profiles,
        [profileId]: { ...before, isFollowing: true },
      },
      followRequestIds: {
        ...state.followRequestIds,
        [profileId]: requestId,
      },
    }));

    try {
      await updateFollowOnServer(profileId, true);
    } catch (error) {
      set((state) => {
        if (state.followRequestIds[profileId] !== requestId) return state;

        return {
          profiles: { ...state.profiles, [profileId]: before },
          followRequestIds: removeKey(state.followRequestIds, profileId),
        };
      });
      throw error;
    }

    set((state) => {
      if (state.followRequestIds[profileId] !== requestId) return state;

      return {
        followRequestIds: removeKey(state.followRequestIds, profileId),
      };
    });
  },
}));

Berikan aturan produk ke Claude Code: apakah klik berulang boleh, apa yang di-rollback saat gagal, apakah toast muncul, dan request mana yang menang saat ada beberapa request.

Test dengan Vitest

Action Zustand bisa dites tanpa render React. Reset store sebelum setiap test, lalu validasi kontrak perilaku.

import { beforeEach, describe, expect, it } from "vitest";
import { useCommerceUiStore } from "./commerce-ui-store";

const initialState = useCommerceUiStore.getInitialState();

beforeEach(() => {
  useCommerceUiStore.setState(initialState, true);
});

describe("commerce ui store", () => {
  it("increments cart quantity and calculates total", () => {
    const store = useCommerceUiStore.getState();

    store.addToCart({ id: "sku-1", name: "Workshop", price: 1200 });
    store.addToCart({ id: "sku-1", name: "Workshop", price: 1200 });

    expect(useCommerceUiStore.getState().cart[0]?.quantity).toBe(2);
    expect(useCommerceUiStore.getState().checkoutTotal()).toBe(2400);
  });

  it("resets the page when a filter changes", () => {
    useCommerceUiStore.getState().setFilters({ page: 4 });
    useCommerceUiStore.getState().setFilters({ keyword: "refund" });

    expect(useCommerceUiStore.getState().filters).toMatchObject({
      keyword: "refund",
      page: 1,
    });
  });

  it("keeps auth UI and toasts explicit", () => {
    useCommerceUiStore.getState().setAuthStatus("signedIn");
    useCommerceUiStore.getState().pushToast({
      kind: "success",
      message: "Saved",
    });

    expect(useCommerceUiStore.getState().auth.status).toBe("signedIn");
    expect(useCommerceUiStore.getState().toasts).toHaveLength(1);
  });
});

Prompt review Claude Code

Review hanya desain state management Zustand pada diff ini.

Cek:
1. Nilai yang cukup local state tidak dipindahkan ke global store.
2. Komponen memakai selector, bukan seluruh store.
3. persist partialize tidak menyimpan PII, token, modal, toast, requestId.
4. SSR/hydration mismatch sudah dipertimbangkan.
5. Async action menangani response lama, klik berulang, dan rollback.
6. devtools dan immer punya alasan konkret.
7. Vitest mencakup action utama dan minimal satu failure path.

Kembalikan:
- Blocking issues
- Suggested patches
- Missing tests
- Questions before merge

Pitfall dan dukungan

Pitfall yang sering muncul adalah meng-global-kan semua state, lupa selector, mem-persist PII, mengabaikan SSR/hydration, membuat async action tanpa race control, dan menambah devtools atau immer karena kebiasaan. Store harus membuat keputusan produk lebih jelas, bukan menyembunyikannya.

Claude Code Lab menyediakan training dan konsultasi untuk mengaudit aplikasi React, memisahkan Zustand dari TanStack Query, menentukan field yang aman dipersist, dan membuat prompt review yang bisa dipakai tim. Catatan verifikasi: per 2 Juni 2026, contoh ini dicek terhadap dokumentasi resmi Zustand untuk create, persist, useShallow, dan testing. Dalam praktik, tim yang lebih dulu membuat tabel field aman untuk persist menghabiskan lebih sedikit waktu untuk membersihkan PII di localStorage dan memperbaiki hydration.

#Claude Code #Zustand #React #state management #TypeScript
Gratis

PDF gratis: cheatsheet Claude Code

Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.

Kami menjaga datamu dan tidak mengirim spam.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.