Zustand Store Organization
Adapt to the project's store location and naming (e.g. src/store/, src/stores/, src/state/). When project-specific rules exist (e.g. zustand-store.mdc), follow those first — this skill covers the structural conventions.
Principles
- One folder per store domain — separate concerns into independent stores rather than one global store.
- Actions live outside the store file — the store file defines state shape and wires hooks; mutation logic sits in
actions/. - Components never call
setdirectly — exposeuseXxxActions()(and granular sub-hooks when needed). - Selectors for reads — prefer dedicated
use*hooks for subscriptions andget*helpers for callbacks/validators. - Server state stays out of Zustand — keep fetched/cached API data in your data-fetching layer (React Query, SWR, etc.); use Zustand for client/UI/editing state.
Directory layout
{storeRoot}/{domain}/
├── {domain}.store.ts # state type, create(), hook exports
├── actions/
│ ├── {domain}.actions.ts # action factory + action types
│ ├── index.ts
│ ├── {domain}.actions.test.ts
│ └── {feature}/ # optional, for large/composite stores
│ ├── v1.actions.ts
│ └── index.ts
├── selectors/
│ ├── {domain}.selectors.ts
│ └── index.ts
└── index.ts # optional barrel
Store file ({domain}.store.ts)
- Export the state type with an
actionsfield. - Create the store with
create<State>(). - Nest all mutations under
actions: { ...domainActions(set, get) }. - Export focused action hooks.
import { create } from 'zustand';
import { domainActions, type DomainStateActions } from './actions';
export type DomainState = {
items: Item[];
selectedId: string | null;
actions: DomainStateActions;
};
export const useDomainStore = create<DomainState>((set, get) => ({
items: [],
selectedId: null,
actions: {
...domainActions(set, get),
},
}));
export const useDomainActions = () =>
useDomainStore((state) => state.actions);
Middleware
Add devtools, persist, or other middleware when the store benefits from debugging, hydration, or persistence. Simple stores can use plain create.
Actions (actions/{domain}.actions.ts)
- Export
{Domain}StateActionswith every mutation signature. - Export a factory:
(set, get?) => DomainStateActions. - Type
set/getfromStoreApi<DomainState>['setState' | 'getState']. - Keep updates immutable (
set((state) => ({ ... }))or shallow merges). - Call sibling actions via
get().actions.otherAction()when needed. - Include
reset()that restores initial state (do not reset theactionsobject itself).
import type { StoreApi } from 'zustand';
import type { DomainState } from '../{domain}.store';
export type DomainStateActions = {
setSelectedId: (id: string | null) => void;
addItem: (item: Item) => void;
reset: () => void;
};
type Set = StoreApi<DomainState>['setState'];
type Get = StoreApi<DomainState>['getState'];
export const domainActions = (set: Set, get: Get): DomainStateActions => ({
setSelectedId: (id) => set({ selectedId: id }),
addItem: (item) => set({ items: [...get().items, item] }),
reset: () => set({ items: [], selectedId: null }),
});
Derived indexes
When lookups by id are frequent, maintain a derived map alongside the list and update both in the same action:
setNodes: (nodes) => {
const byId = nodes.reduce(
(acc, node) => ({ ...acc, [node.id]: node }),
{} as Record<string, Node>
);
set({ nodes, byId });
},
Composite / versioned actions
For stores with many sub-domains (e.g. per-entity-type config, API versions):
- Root registry in
actions/{domain}.actions.tscomposes sub-factories. - Each sub-module exports its own actions type + factory.
- Nested shape:
actions.{feature}.v1.updateField(...). - Export granular hooks from the store file:
export const useFeatureV1Actions = () =>
useDomainStore((state) => state.actions.feature.v1);
Keep shared CRUD (init, update metadata, reset) in common.actions.ts.
Selectors (selectors/)
| Pattern | Naming | Usage |
|---|---|---|
| Reactive hook | useXxx |
Components subscribing to slices |
| Non-reactive getter | getXxx |
Callbacks, validators, save handlers |
| Composite slice | {domain}Selector |
Related state + actions for one feature |
| Narrow pick | useShallow |
Derived objects/arrays to avoid re-renders |
export const useSelectedItem = () =>
useDomainStore((state) =>
state.items.find((i) => i.id === state.selectedId)
);
export const getItems = () => useDomainStore.getState().items;
export const domainSelector = (state: DomainState) => ({
items: state.items,
addItem: state.actions.addItem,
});
Group domain-specific selectors in the same file with section comments.
Staging store pattern
Use when an edit flow needs cancel/undo without touching canonical state:
Authoritative store Staging store Validation store (optional)
─────────────────── ───────────── ───────────────────────────
canonical entities → draft copy on open errors keyed by entity id
edit via staging actions
Save: validate → patch authoritative → reset staging → clear errors
Cancel: reset staging only (authoritative unchanged)
Typical split: one store holds the source of truth; a second holds in-progress edits; a third (optional) holds field-level validation errors.
Component usage
- Read:
useDomainStore((s) => s.field)or dedicated selector hooks. - Write:
useDomainActions()or granular action hooks — neversetin components. - Outside render (save handlers, validators):
getXxx()viastore.getState().
Testing actions
Test action factories in isolation — no React, no full store:
- Build a mutable
stateobject; mockset/get. - Instantiate
domainActions(set, get)and attach tostate.actions. - Assert state via
getState()after calling actions.
See examples.md for templates.
New store checklist
- [ ] Create {storeRoot}/{domain}/{domain}.store.ts with State type + create()
- [ ] Add actions/{domain}.actions.ts with typed factory + reset
- [ ] Wire actions in store; export useDomainActions (+ granular hooks if nested)
- [ ] Add selectors for commonly read slices (use* + get* pairs)
- [ ] Add actions/index.ts and selectors/index.ts re-exports
- [ ] Add actions/{domain}.actions.test.ts for non-trivial logic
- [ ] Components use action hooks only; no direct setState on store
Anti-patterns
- Business logic inline in the store file instead of
actions/. - Subscribing to the entire store when a narrow selector suffices.
- Mixing fetched/server data into Zustand when a cache layer already owns it.
- Forgetting
get()when an action depends on current state. - Skipping
reset()on stores tied to route/panel/modal lifecycle.
Additional resources
- Full templates: examples.md