State management Zustand dengan Claude Code
Rancang store Zustand dengan Claude Code: selector, persist parsial, async action, test, dan prompt review.
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 case | Masuk Zustand | Tidak masuk | Instruksi untuk Claude Code |
|---|---|---|---|
| Filter admin | keyword, status, page, pageSize | respons API lengkap | Pisahkan nilai URL dan UI |
| Cart | SKU, quantity, display price | payment session, stok asli | Persist hanya field rendah risiko |
| Auth UI | login dialog, checking state | token, email, alamat | Jangan simpan PII atau secret |
| Modal/toast | activeModal, toast queue pendek | audit log, error panjang | Simpan hanya yang dirender UI |
| Optimistic update | requestId, snapshot sebelumnya | kebenaran final server | Tentukan 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.
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.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Workflow Obsidian ke CLAUDE.md untuk Claude Code
Ubah catatan kerja Obsidian menjadi operating note CLAUDE.md agar konteks tidak dijelaskan ulang.
Claude Code Revenue CTA Routing: dari artikel ke PDF, Gumroad, dan konsultasi
Workflow Claude Code untuk mengarahkan pembaca ke PDF gratis, Gumroad, atau konsultasi sesuai intent.
Aturan handoff tim Claude Code: bukti review, permission, rollback, dan jalur revenue
Format handoff Claude Code untuk tim: bukti, permission rule, rollback, PDF gratis, Gumroad, dan konsultasi.