Build CoreOrdered learning track

Zustand in Production

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 063

Zustand sebagai external-store pragmatic layer untuk React production: store slices, selectors, middleware, persistence, vanilla store, testing, dan failure modes.

7 min read1280 words
PrevNext
Lesson 63123 lesson track24–67 Build Core
#react#zustand#state-management#external-store+3 more

Part 063 — Zustand in Production

Part sebelumnya membangun selector-based subscription dari nol. Sekarang kita naik satu tingkat: menggunakan library external store yang pragmatic.

Zustand sering dipilih karena API-nya kecil, tidak membutuhkan Provider secara default, cocok untuk local-global state yang bukan server cache, dan mudah dipecah menjadi action/selectors. Namun justru karena kecil dan bebas, Zustand mudah berubah menjadi global mutable dump kalau tidak diberi boundary.

Mental model utama:

Zustand bukan tempat semua state.
Zustand adalah external store untuk state client yang:
- perlu dibaca lintas subtree,
- berubah cukup sering,
- tidak cocok di Context karena fan-out,
- bukan otoritas server,
- punya lifecycle lebih panjang dari satu component,
- dan butuh subscription granular.

React menyediakan useSyncExternalStore untuk membaca store di luar React secara aman. Zustand berada di kategori ini: sebuah store eksternal dengan hook adapter yang membuat component subscribe ke slice state tertentu.


1. Posisi Zustand Dalam Taxonomy State

Jangan mulai dari pertanyaan:

Apakah kita pakai Zustand?

Mulai dari pertanyaan:

State ini milik siapa?
Berapa luas radius pembacanya?
Siapa yang boleh mengubahnya?
Apakah state ini otoritatif dari server?
Apakah perlu survive navigation?
Apakah perubahan kecil boleh memicu rerender banyak component?

Zustand cocok untuk:

Jenis stateContohCocok?Catatan
UI shell statesidebar, command palette, layout modeYaExternal store kecil, selector granular
Client-only workflow stateselected rows, open panels, local draft lintas pageYaSelama bukan workflow kompleks yang butuh state machine
Cross-tree command statemodal manager, toast queueYaLebih baik sebagai capability store kecil
Server cacheuser detail, list invoice dari APITidak defaultGunakan TanStack Query / RTK Query / framework cache
Auth token authorityaccess token, refresh tokenHati-hatiJangan asal persist; keamanan dan lifecycle kritis
Permission modelcapability snapshotBisaTapi keputusan final tetap server-side
Form field internal stateinput text biasaBiasanya tidakCo-locate di form/component
High-frequency pointer statemouse position per frameBiasanya tidakBisa terlalu banyak update; pakai ref/requestAnimationFrame

Diagram posisi:


2. Apa yang Membuat Zustand Menarik

Zustand punya beberapa karakter penting:

- store berada di luar React tree,
- component bisa subscribe ke selector tertentu,
- action bisa didefinisikan bersama state,
- tidak perlu reducer boilerplate,
- middleware bisa menambah persist/devtools/immer/subscribeWithSelector,
- bisa digunakan sebagai vanilla store tanpa React,
- mudah dites karena store bisa dibuat sebagai object/function biasa.

Contoh minimal:

import { create } from 'zustand';

type SidebarState = {
  collapsed: boolean;
  toggle: () => void;
  setCollapsed: (collapsed: boolean) => void;
};

export const useSidebarStore = create<SidebarState>((set) => ({
  collapsed: false,
  toggle: () => set((state) => ({ collapsed: !state.collapsed })),
  setCollapsed: (collapsed) => set({ collapsed }),
}));

Consumer:

function SidebarButton() {
  const collapsed = useSidebarStore((state) => state.collapsed);
  const toggle = useSidebarStore((state) => state.toggle);

  return (
    <button onClick={toggle}>
      {collapsed ? 'Expand' : 'Collapse'}
    </button>
  );
}

Prinsip penting:

Subscribe ke field/action yang dipakai.
Jangan subscribe ke seluruh state kecuali component memang butuh seluruh state.

Anti-pattern:

// Anti-pattern untuk component besar
const store = useSidebarStore();

Lebih baik:

const collapsed = useSidebarStore((s) => s.collapsed);
const toggle = useSidebarStore((s) => s.toggle);

3. Zustand Bukan Context Replacement Total

Zustand sering dipromosikan sebagai “tidak perlu Provider”. Itu benar untuk banyak kasus client-side SPA, tetapi bukan berarti semua store harus module-singleton.

Ada dua bentuk store:

1. Singleton store
   - module-level store
   - satu instance untuk seluruh app/browser tab
   - cocok untuk app shell state

2. Scoped store
   - instance dibuat per page/subtree/request
   - disediakan lewat Context
   - cocok untuk multi-instance feature, SSR, test isolation

Singleton:

export const useCommandPaletteStore = create<CommandPaletteState>((set) => ({
  open: false,
  openPalette: () => set({ open: true }),
  closePalette: () => set({ open: false }),
}));

Scoped store:

import { createContext, useContext, useRef } from 'react';
import { createStore, useStore } from 'zustand';

type DraftState = {
  title: string;
  setTitle: (title: string) => void;
};

function createDraftStore(initialTitle: string) {
  return createStore<DraftState>((set) => ({
    title: initialTitle,
    setTitle: (title) => set({ title }),
  }));
}

type DraftStore = ReturnType<typeof createDraftStore>;

const DraftStoreContext = createContext<DraftStore | null>(null);

export function DraftProvider({
  initialTitle,
  children,
}: {
  initialTitle: string;
  children: React.ReactNode;
}) {
  const storeRef = useRef<DraftStore | null>(null);

  if (storeRef.current === null) {
    storeRef.current = createDraftStore(initialTitle);
  }

  return (
    <DraftStoreContext.Provider value={storeRef.current}>
      {children}
    </DraftStoreContext.Provider>
  );
}

export function useDraftSelector<T>(selector: (state: DraftState) => T): T {
  const store = useContext(DraftStoreContext);

  if (!store) {
    throw new Error('useDraftSelector must be used inside <DraftProvider>.');
  }

  return useStore(store, selector);
}

Gunakan scoped store saat:

- satu page bisa punya banyak instance feature yang sama,
- store perlu initial props berbeda,
- SSR/request isolation penting,
- test perlu isolated store,
- state harus reset saat subtree remount,
- global singleton berisiko bocor lintas user/session.

4. Store Anatomy: State, Actions, Selectors, Effects

Zustand store production sebaiknya punya anatomi eksplisit:

store/
  invoice-review.store.ts
  invoice-review.selectors.ts
  invoice-review.types.ts
  invoice-review.test.ts

Contoh domain UI store:

type InvoiceReviewState = {
  selectedInvoiceIds: string[];
  panel: 'none' | 'details' | 'comments' | 'history';
  sort: { field: 'createdAt' | 'amount'; direction: 'asc' | 'desc' };

  selectOne: (id: string) => void;
  clearSelection: () => void;
  openPanel: (panel: InvoiceReviewState['panel']) => void;
  setSort: (sort: InvoiceReviewState['sort']) => void;
};

export const useInvoiceReviewStore = create<InvoiceReviewState>((set) => ({
  selectedInvoiceIds: [],
  panel: 'none',
  sort: { field: 'createdAt', direction: 'desc' },

  selectOne: (id) =>
    set((state) => ({
      selectedInvoiceIds: state.selectedInvoiceIds.includes(id)
        ? state.selectedInvoiceIds
        : [...state.selectedInvoiceIds, id],
    })),

  clearSelection: () => set({ selectedInvoiceIds: [] }),

  openPanel: (panel) => set({ panel }),

  setSort: (sort) => set({ sort }),
}));

Selectors:

export const selectSelectedInvoiceIds = (state: InvoiceReviewState) =>
  state.selectedInvoiceIds;

export const selectSelectedCount = (state: InvoiceReviewState) =>
  state.selectedInvoiceIds.length;

export const selectIsInvoiceSelected = (id: string) =>
  (state: InvoiceReviewState) => state.selectedInvoiceIds.includes(id);

Consumer:

function SelectionBadge() {
  const count = useInvoiceReviewStore(selectSelectedCount);
  return <span>{count} selected</span>;
}

function InvoiceRow({ id }: { id: string }) {
  const selected = useInvoiceReviewStore(selectIsInvoiceSelected(id));
  const selectOne = useInvoiceReviewStore((s) => s.selectOne);

  return (
    <button aria-pressed={selected} onClick={() => selectOne(id)}>
      {id}
    </button>
  );
}

Catatan: selector factory seperti selectIsInvoiceSelected(id) menghasilkan function baru jika dipanggil tiap render. Ini biasanya masih aman, tetapi untuk list besar perlu profiling. Bisa memoize selector factory bila identity selector terbukti jadi masalah.


5. Jangan Bocorkan set Mentah Ke Component

Anti-pattern:

type Store = {
  selectedIds: string[];
  setSelectedIds: (ids: string[]) => void;
};

Masalahnya component bebas membuat illegal state:

setSelectedIds(['unknown-id', 'duplicate-id', 'duplicate-id']);

Lebih baik expose command/intention:

type Store = {
  selectedIds: string[];
  select: (id: string) => void;
  deselect: (id: string) => void;
  toggle: (id: string) => void;
  clear: () => void;
};

Implementasi:

const useSelectionStore = create<Store>((set, get) => ({
  selectedIds: [],

  select: (id) =>
    set((state) => {
      if (state.selectedIds.includes(id)) return state;
      return { selectedIds: [...state.selectedIds, id] };
    }),

  deselect: (id) =>
    set((state) => ({
      selectedIds: state.selectedIds.filter((x) => x !== id),
    })),

  toggle: (id) => {
    const { selectedIds, select, deselect } = get();
    if (selectedIds.includes(id)) deselect(id);
    else select(id);
  },

  clear: () => set({ selectedIds: [] }),
}));

Prinsip:

Public action harus menjaga invariant.
Component hanya mengirim intent.
Store memutuskan transisi.

6. Slice Pattern: Batas Modular, Bukan Dump Besar

Saat store membesar, jangan buat object raksasa tanpa struktur. Gunakan slice pattern.

type SelectionSlice = {
  selectedIds: string[];
  toggleSelected: (id: string) => void;
  clearSelection: () => void;
};

type PanelSlice = {
  panel: 'none' | 'details' | 'comments';
  openPanel: (panel: PanelSlice['panel']) => void;
  closePanel: () => void;
};

type InvoiceUiStore = SelectionSlice & PanelSlice;

Slice creator:

import type { StateCreator } from 'zustand';

const createSelectionSlice: StateCreator<InvoiceUiStore, [], [], SelectionSlice> =
  (set) => ({
    selectedIds: [],
    toggleSelected: (id) =>
      set((state) => ({
        selectedIds: state.selectedIds.includes(id)
          ? state.selectedIds.filter((x) => x !== id)
          : [...state.selectedIds, id],
      })),
    clearSelection: () => set({ selectedIds: [] }),
  });

const createPanelSlice: StateCreator<InvoiceUiStore, [], [], PanelSlice> =
  (set) => ({
    panel: 'none',
    openPanel: (panel) => set({ panel }),
    closePanel: () => set({ panel: 'none' }),
  });

export const useInvoiceUiStore = create<InvoiceUiStore>()((...args) => ({
  ...createSelectionSlice(...args),
  ...createPanelSlice(...args),
}));

Slice rule:

Slice boleh berkoordinasi dengan slice lain, tetapi jangan membuat dependency graph yang tidak terlihat.
Jika transisi melibatkan beberapa slice, pertimbangkan satu action orchestration eksplisit di root store.

Contoh action orchestration:

type InvoiceUiStore = SelectionSlice & PanelSlice & {
  resetWorkspace: () => void;
};

export const useInvoiceUiStore = create<InvoiceUiStore>()((set, get, api) => ({
  ...createSelectionSlice(set, get, api),
  ...createPanelSlice(set, get, api),

  resetWorkspace: () =>
    set({
      selectedIds: [],
      panel: 'none',
    }),
}));

7. Selector Discipline

Selector adalah read contract.

Baik:

const selectedCount = useInvoiceUiStore((s) => s.selectedIds.length);

Buruk:

const { selectedIds, panel, sort, filters } = useInvoiceUiStore();

Masalah selector object:

const value = useInvoiceUiStore((s) => ({
  count: s.selectedIds.length,
  panel: s.panel,
}));

Selector ini menghasilkan object baru setiap pemanggilan. Tanpa equality check, component bisa rerender lebih sering.

Gunakan shallow comparison jika memilih beberapa field:

import { shallow } from 'zustand/shallow';

const value = useInvoiceUiStore(
  (s) => ({
    count: s.selectedIds.length,
    panel: s.panel,
  }),
  shallow
);

Atau pilih field terpisah:

const count = useInvoiceUiStore((s) => s.selectedIds.length);
const panel = useInvoiceUiStore((s) => s.panel);

Decision:

KebutuhanPola
Satu primitiveselector langsung
Beberapa primitivebeberapa selector atau object + shallow
Derived expensiveselector function + memo di layer lain bila perlu
Entity by IDselector factory
Large list rowsubscribe per row ke entity spesifik

8. Structural Sharing dan Immutable Update

Zustand tidak memaksa reducer, tetapi React subscription tetap bergantung pada perubahan referensi yang bermakna.

Buruk:

set((state) => {
  state.selectedIds.push(id);
  return state;
});

Lebih baik:

set((state) => ({
  selectedIds: [...state.selectedIds, id],
}));

Untuk nested update, gunakan helper atau Immer middleware bila kompleks.

Tanpa Immer:

set((state) => ({
  entities: {
    ...state.entities,
    [id]: {
      ...state.entities[id],
      status: 'approved',
    },
  },
}));

Dengan Immer middleware:

import { immer } from 'zustand/middleware/immer';

export const useInvoiceStore = create<InvoiceStore>()(
  immer((set) => ({
    entities: {},
    approve: (id) =>
      set((state) => {
        state.entities[id].status = 'approved';
      }),
  }))
);

Catatan:

Immer memudahkan update nested.
Immer bukan izin untuk membuat state shape buruk.
Jika nested update terlalu sering, evaluasi normalisasi state.

9. Persistence: Jangan Persist Semua

Persist middleware berguna untuk preference dan draft tertentu. Namun persistence adalah boundary serius:

- data bisa basi,
- schema bisa berubah,
- user bisa logout,
- storage bisa corrupt,
- hydration bisa berbeda dari server render,
- sensitive data bisa bocor,
- multi-tab bisa diverge.

Contoh persist preference:

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

type PreferencesStore = {
  density: 'comfortable' | 'compact';
  setDensity: (density: PreferencesStore['density']) => void;
};

export const usePreferencesStore = create<PreferencesStore>()(
  persist(
    (set) => ({
      density: 'comfortable',
      setDensity: (density) => set({ density }),
    }),
    {
      name: 'preferences:v1',
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({ density: state.density }),
      version: 1,
    }
  )
);

Gunakan partialize:

partialize: (state) => ({
  density: state.density,
  sidebarCollapsed: state.sidebarCollapsed,
})

Jangan persist:

- access token tanpa threat model,
- server cache mentah tanpa invalidation,
- permission decision sebagai otoritas,
- volatile loading/error state,
- function/action,
- data besar tanpa quota strategy,
- temporary optimistic state tanpa reconciliation.

Schema migration:

persist(
  (set) => ({
    density: 'comfortable',
    sidebar: { collapsed: false },
    setDensity: (density) => set({ density }),
  }),
  {
    name: 'preferences',
    version: 2,
    migrate: (persisted, version) => {
      if (version === 1) {
        const old = persisted as { compactMode?: boolean };
        return {
          density: old.compactMode ? 'compact' : 'comfortable',
          sidebar: { collapsed: false },
        };
      }
      return persisted as PreferencesStore;
    },
  }
);

Production rule:

Persisted state harus dianggap input tidak tepercaya.
Validasi, versi, migrasikan, dan punya fallback reset.

10. Devtools Middleware

Devtools berguna untuk debugging action/state transition.

import { devtools } from 'zustand/middleware';

export const useInvoiceUiStore = create<InvoiceUiStore>()(
  devtools(
    (set) => ({
      selectedIds: [],
      toggleSelected: (id) =>
        set(
          (state) => ({
            selectedIds: state.selectedIds.includes(id)
              ? state.selectedIds.filter((x) => x !== id)
              : [...state.selectedIds, id],
          }),
          false,
          'selection/toggleSelected'
        ),
    }),
    { name: 'InvoiceUiStore' }
  )
);

Naming action penting. Jangan biarkan semua muncul sebagai anonymous set.

Good action names:
- selection/toggleSelected
- panel/openDetails
- filters/apply
- workspace/reset

Bad action names:
- set
- update
- change
- doStuff

11. subscribeWithSelector: Outside-React Reactions

Kadang kita perlu bereaksi terhadap perubahan store di luar component React. Misalnya analytics, storage sync, command bridge, atau integration dengan non-React code.

Gunakan middleware subscribeWithSelector.

import { subscribeWithSelector } from 'zustand/middleware';

const useInvoiceStore = create<InvoiceUiStore>()(
  subscribeWithSelector((set) => ({
    selectedIds: [],
    toggleSelected: (id) =>
      set((state) => ({
        selectedIds: state.selectedIds.includes(id)
          ? state.selectedIds.filter((x) => x !== id)
          : [...state.selectedIds, id],
      })),
  }))
);

const unsubscribe = useInvoiceStore.subscribe(
  (state) => state.selectedIds.length,
  (count, previousCount) => {
    console.log({ count, previousCount });
  }
);

Lifecycle rule:

Jika subscribe dilakukan di module scope, pastikan memang global sepanjang app.
Jika subscribe dilakukan karena component hidup, lakukan di effect dan cleanup.
function SelectionAnalyticsBridge() {
  useEffect(() => {
    return useInvoiceStore.subscribe(
      (state) => state.selectedIds.length,
      (count) => analytics.track('selection_count_changed', { count })
    );
  }, []);

  return null;
}

Failure mode:

subscribe tanpa cleanup = memory leak.
subscribe yang dispatch lagi tanpa guard = event loop/cycle.
subscribe untuk derivasi state = kemungkinan duplikasi source of truth.

12. Async Actions: Hati-Hati Dengan Server State

Zustand mengizinkan async action:

type UserStore = {
  user: User | null;
  loading: boolean;
  error: string | null;
  loadUser: (id: string) => Promise<void>;
};

const useUserStore = create<UserStore>((set) => ({
  user: null,
  loading: false,
  error: null,

  loadUser: async (id) => {
    set({ loading: true, error: null });

    try {
      const user = await api.getUser(id);
      set({ user, loading: false });
    } catch (error) {
      set({ error: 'Failed to load user', loading: false });
    }
  },
}));

Ini valid untuk small apps atau integration state, tetapi untuk server state serius sering kurang:

- no built-in staleTime/cacheTime,
- no automatic deduplication,
- no query key semantics,
- no retry policy by default,
- no background refetch,
- no mutation invalidation model,
- harder SSR dehydration/hydration story.

Untuk server-owned data, lebih baik:

- TanStack Query,
- RTK Query,
- framework loader/cache,
- React Server Components/data layer sesuai framework.

Zustand tetap boleh menyimpan:

- selected entity IDs,
- UI filters draft sebelum commit ke URL/query,
- active panel for entity detail,
- optimistic client-only queue dengan reconciliation eksplisit,
- command state yang mengarahkan mutation ke query layer.

Pattern:

function ApproveButton({ invoiceId }: { invoiceId: string }) {
  const closePanel = useInvoiceUiStore((s) => s.closePanel);
  const mutation = useApproveInvoiceMutation();

  return (
    <button
      onClick={async () => {
        await mutation.mutateAsync({ invoiceId });
        closePanel();
      }}
    >
      Approve
    </button>
  );
}

Zustand mengatur UI command state. Server-state cache mengatur remote data.


13. Request Identity Guard Jika Async Tetap Di Store

Jika async action tetap berada di Zustand, minimal jaga race.

type SearchStore = {
  query: string;
  result: SearchResult | null;
  status: 'idle' | 'loading' | 'success' | 'error';
  requestId: string | null;
  search: (query: string) => Promise<void>;
};

const useSearchStore = create<SearchStore>((set, get) => ({
  query: '',
  result: null,
  status: 'idle',
  requestId: null,

  search: async (query) => {
    const requestId = crypto.randomUUID();

    set({ query, status: 'loading', requestId });

    try {
      const result = await api.search(query);

      if (get().requestId !== requestId) {
        return;
      }

      set({ result, status: 'success' });
    } catch {
      if (get().requestId !== requestId) {
        return;
      }

      set({ status: 'error' });
    }
  },
}));

Invariant:

Only latest request may commit result.

14. Store Lifetime dan Reset

Global Zustand store tidak reset otomatis saat component unmount. Itulah point-nya. Tapi ini juga sumber bug.

Contoh bug:

User membuka Case A.
Panel comments terbuka.
User pindah ke Case B.
Panel comments masih terbuka karena store global tidak reset.

Solusi 1 — route/page reset:

function CasePage({ caseId }: { caseId: string }) {
  const reset = useCaseUiStore((s) => s.reset);

  useEffect(() => {
    reset({ caseId });
  }, [caseId, reset]);

  return <CaseWorkspace caseId={caseId} />;
}

Solusi 2 — scoped store per page:

<CaseUiProvider caseId={caseId}>
  <CaseWorkspace />
</CaseUiProvider>

Solusi 3 — key remount provider:

<CaseUiProvider key={caseId} caseId={caseId}>
  <CaseWorkspace />
</CaseUiProvider>

Decision:

LifetimeStore type
Entire browser sessionsingleton
Per route entityscoped store keyed by entity ID
Per modal instancescoped store inside modal root
Per testnew vanilla store per test
Per SSR requestnew store per request

15. Zustand and SSR/Hydration

Singleton module store can leak across requests in SSR environments if created on the server and shared globally.

Production rule:

For request-scoped SSR state, create store per request/render boundary.
Do not share mutable singleton store across users on the server.

Pattern:

function createAppStore(initialState: Partial<AppStoreState>) {
  return createStore<AppStoreState>()((set) => ({
    sidebarCollapsed: initialState.sidebarCollapsed ?? false,
    setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }),
  }));
}

Then inject into Context provider for the client subtree.

Hydration mismatch risk:

- server renders default collapsed=false,
- client persisted storage hydrates collapsed=true,
- first client paint differs from server output.

Mitigation:

- only use persisted preference after mounted,
- gate non-critical UI until hydration,
- serialize initial server snapshot,
- choose CSS that tolerates initial default,
- keep persisted state out of critical SSR markup when possible.

16. TypeScript Store Contracts

Avoid broad any store. Define domain types.

type LoadStatus =
  | { tag: 'idle' }
  | { tag: 'loading'; requestId: string }
  | { tag: 'success' }
  | { tag: 'error'; message: string };

type CaseUiState = {
  activeTab: 'summary' | 'evidence' | 'timeline';
  selectedEvidenceIds: string[];
  loadStatus: LoadStatus;
};

type CaseUiActions = {
  openTab: (tab: CaseUiState['activeTab']) => void;
  toggleEvidence: (id: string) => void;
  clearEvidenceSelection: () => void;
};

type CaseUiStore = CaseUiState & CaseUiActions;

Prefer:

- discriminated union for lifecycle state,
- branded IDs for domain IDs if useful,
- command names over generic setters,
- selectors typed from store state,
- store factory for test/SSR.

Avoid:

- `Record<string, any>`,
- raw backend DTO as editable UI state,
- optional fields that create ambiguous illegal states,
- action payloads that accept arbitrary partial state.

17. Testing Zustand Store

Because store can be vanilla, test it without React first.

import { createStore } from 'zustand/vanilla';

function createSelectionStore() {
  return createStore<SelectionStore>()((set) => ({
    selectedIds: [],
    toggle: (id) =>
      set((state) => ({
        selectedIds: state.selectedIds.includes(id)
          ? state.selectedIds.filter((x) => x !== id)
          : [...state.selectedIds, id],
      })),
  }));
}

test('toggle selects and deselects id', () => {
  const store = createSelectionStore();

  store.getState().toggle('inv-1');
  expect(store.getState().selectedIds).toEqual(['inv-1']);

  store.getState().toggle('inv-1');
  expect(store.getState().selectedIds).toEqual([]);
});

Test subscription behavior:

test('notifies selected count changes', () => {
  const store = createSelectionStore();
  const listener = vi.fn();

  const unsubscribe = store.subscribe(listener);

  store.getState().toggle('a');
  store.getState().toggle('b');

  expect(listener).toHaveBeenCalledTimes(2);

  unsubscribe();
});

For React integration:

render(
  <SelectionProvider store={createSelectionStore()}>
    <SelectionBadge />
  </SelectionProvider>
);

Testing rule:

Test store transitions at store level.
Test component rendering at integration boundary.
Do not assert implementation detail of Zustand internals.

18. Observability and Debugging

Zustand state bugs are often caused by invisible global lifetime. Add observability where transitions matter.

const useAuditStore = create<AuditStore>()(
  devtools((set) => ({
    status: { tag: 'idle' },
    submitStarted: (requestId) =>
      set({ status: { tag: 'submitting', requestId } }, false, 'submit/started'),
    submitSucceeded: (requestId) =>
      set({ status: { tag: 'success', requestId } }, false, 'submit/succeeded'),
  }))
);

Debug protocol:

1. Which component reads the state?
2. Which action changed it?
3. Was the store singleton or scoped?
4. Did selector return stable value?
5. Did a persisted value override initial value?
6. Did async response commit after newer request?
7. Did component subscribe to too much state?
8. Was state reset when route/entity changed?

19. Zustand vs Reducer+Context vs Redux Toolkit

DimensionReducer + ContextZustandRedux Toolkit
Setup costLowLowMedium
Provider neededYesNo by defaultYes
Fine-grained subscriptionWeak by defaultStrongStrong with selectors
GovernanceLocalMediumStrong
DevtoolsManualMiddlewareFirst-class ecosystem
Async architectureManualManualThunk/listener/RTK Query
Team conventionsYou defineYou must defineToolkit conventions
Best fitScoped subtree statePragmatic client storeLarge shared app state with auditability

Rule:

Use Zustand when you need external-store ergonomics without Redux governance weight.
Use Redux Toolkit when the cost of freedom is higher than boilerplate cost.

20. Production Folder Patterns

Small feature:

features/invoice-review/
  invoice-review.store.ts
  invoice-review.selectors.ts
  invoice-review.types.ts
  InvoiceReviewPage.tsx

Larger feature:

features/case-workspace/
  state/
    case-workspace.store.ts
    case-workspace.selectors.ts
    case-workspace.actions.ts
    case-workspace.types.ts
    create-case-workspace-store.ts
  components/
    CaseWorkspaceShell.tsx
    EvidencePanel.tsx
    TimelinePanel.tsx

Do not create:

stores/
  app.store.ts   // 2000 lines of everything

Unless the app is tiny.


21. Common Failure Modes

21.1 Store Becomes Global Dump

Symptom:

useAppStore contains user, theme, modal, filters, entities, server data, loading flags, permissions, toasts, page state.

Cause:

No ownership decision.

Fix:

Split by lifecycle and authority, not by convenience.

21.2 Component Subscribes to Whole Store

Symptom:

const state = useAppStore();

Fix:

const collapsed = useAppStore((s) => s.sidebarCollapsed);

21.3 Selector Returns New Object Every Time

Symptom:

const vm = useStore((s) => ({ a: s.a, b: s.b }));

Fix:

const vm = useStore((s) => ({ a: s.a, b: s.b }), shallow);

Or split selectors.


21.4 Server State Stored as Client State

Symptom:

Manual fetch, manual loading flags, manual stale cache, manual invalidation everywhere.

Fix:

Move server-owned data to server-state cache.
Keep Zustand for UI state around it.

21.5 Persistence Without Versioning

Symptom:

App crashes after deploy because old localStorage shape no longer matches code.

Fix:

Version, migrate, validate, reset fallback.

21.6 Singleton Store Leaks Across SSR Requests

Symptom:

User A sees User B state in SSR environment.

Fix:

Create store per request. Inject through provider.

21.7 Async Race Commits Stale Data

Symptom:

Slow response from old query overwrites faster response from new query.

Fix:

Request identity guard or use server-state cache.

22. Refactor Ladder

When local state is not enough:

useState
→ useReducer
→ reducer + Context
→ mini external store
→ Zustand
→ Redux Toolkit / state machine / server-state cache depending on problem

Do not jump directly to Zustand if the real issue is bad state shape.


23. Production Checklist

Sebelum merge Zustand store:

[ ] State ini bukan server-owned cache.
[ ] Store lifetime jelas: singleton/scoped/per-request.
[ ] Store punya owner feature yang jelas.
[ ] Component memakai selectors, bukan root store snapshot.
[ ] Action menjaga invariant, bukan raw setter liar.
[ ] State shape meminimalkan duplicated source of truth.
[ ] Persistence hanya untuk field yang memang perlu.
[ ] Persisted state punya version/migration jika schema bisa berubah.
[ ] Sensitive data tidak dipersist tanpa threat model.
[ ] Async action punya request identity guard atau dipindah ke query layer.
[ ] Store reset saat route/entity/session berubah.
[ ] Tests mencakup transition penting.
[ ] Devtools/action names cukup jelas untuk debugging.

24. Latihan Implementasi

Bangun case-workspace.store.ts untuk regulatory case management UI.

Requirement:

- activeTab: summary | evidence | timeline | decision
- selectedEvidenceIds
- rightPanel: none | evidence-detail | comment-thread
- density: comfortable | compact, persisted
- command: openEvidence(id), closePanel(), toggleEvidence(id), resetWorkspace(caseId)
- selector: selectedEvidenceCount, isEvidenceSelected(id), canBulkReview
- request/lifetime: reset ketika caseId berubah

Constraint:

- Jangan simpan detail evidence dari server di Zustand.
- Simpan hanya selected IDs dan UI panel state.
- Buat test untuk toggle, reset, dan selector.
- Gunakan persist hanya untuk density.

Stretch:

- Buat scoped store provider per caseId.
- Tambahkan devtools action name.
- Tambahkan subscribeWithSelector untuk analytics selected count.

25. Ringkasan

Zustand kuat karena kecil. Zustand berbahaya karena kecil.

Gunakan dengan disiplin:

- pilih state yang memang client-owned,
- desain action sebagai command/intention,
- gunakan selector granular,
- kontrol lifetime,
- jangan jadikan server cache,
- persist hanya yang aman dan perlu,
- buat store factory untuk scoped/test/SSR,
- ukur performance sebelum membuat abstraksi berlebihan.

Zustand yang baik terasa seperti external-store boundary yang tenang. Zustand yang buruk terasa seperti global variable dengan hook API.


Referensi

Lesson Recap

You just completed lesson 63 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.