$ npx @airuleshub/cli@latest add pure-state-zustand-rules-conventionsRule Content
Pure State — Zustand Patterns That Actually Scale
AI Instruction: This document defines the rules, conventions, and architecture decisions for implementing Zustand state management in a React + TypeScript project. Do not deviate from these patterns. When generating stores, hooks, or components — always refer back to these rules first.
Stack
- Zustand v4+ — primary state management
- TypeScript strict mode — all stores must be fully typed
- Immer middleware — for any nested state mutations
- DevTools middleware — applied to every store, always
- Persist middleware — only for state that must survive page refresh
Folder Structure
Store Architecture Rules
One Store Per Domain
Every store must own exactly one concern. Never mix auth logic with UI state, or user profile with cart data. If two pieces of state are unrelated, they belong in separate stores. The store name must clearly reflect what it owns — useAuthStore, useCartStore, useUIStore.
Always Define an Initial State Object
Every store must declare a separate initialState const above the store definition. This object is what the reset() action returns to. It also documents at a glance what shape the store holds without reading the full type definition.
Every Store Must Have a Reset Action
Without exception, every store must implement a reset() action that restores the store to its initialState. This is called on logout, session expiry, or any global teardown. A store without reset() is incomplete.
Always Apply DevTools Middleware
Every store — no matter how small — must be wrapped with devtools() middleware. The store name must be passed as the name option. Every set() call must include a descriptive action name as the third argument so that DevTools traces are meaningful and debuggable.
Actions Belong Inside the Store
No business logic or state mutation should live outside the store. Components call store actions — they never call set() directly or manage async themselves. If a component is doing setState based on API data, that logic needs to move into a store action.
TypeScript Conventions
Always Separate State from Actions in the Interface
Define the store interface with a clear visual separation — state fields first, then actions. Use comments to divide them. This makes the interface scannable and prevents confusion between what is data and what is callable.
Use Strict Typing for All State Fields
Never use any. Every field must have an explicit type. Optional fields must be typed as T | null — never as T | undefined unless the field is genuinely optional in a form or partial update context. Null signals "not yet loaded". Undefined signals "doesn't exist".
Infer Action Parameter Types from Zod Schemas When Available
If the project uses Zod for API validation, action input types should be inferred from those schemas using z.infer<typeof schema>. Never duplicate type definitions between Zod schemas and store interfaces.
Async Action Rules
Every Async Action Must Follow the Three-Phase Pattern
When an async action starts, immediately set isLoading: true and error: null. On success, set the result data and isLoading: false. On failure, set the error message and isLoading: false. No async action is complete without all three phases handled.
Always Name Async Action States in DevTools
When calling set() inside an async action, name each phase clearly — for example login/pending, login/fulfilled, login/rejected. This mirrors Redux Toolkit conventions and makes DevTools traces readable.
Never Let Errors Silently Fail
Every catch block must write to the store's error field. The error message must be a human-readable string. Always check if the caught value is an instance of Error before accessing .message — otherwise fall back to a generic string.
Access Current State Inside Async Actions with get()
When an async action needs to read current state mid-execution — for example to attach a token to a request — use get() from the store creator. Never close over state values from outside the action, as they may be stale.
Never Trigger Actions from Inside Other Actions
Actions must not call sibling actions directly. If two actions share logic, extract that logic into a private utility function inside the store file and call it from both. Cross-action dependencies cause unpredictable execution order.
Selector Rules
Never Subscribe to the Whole Store
Components must never destructure the full store object. Subscribing to the whole store causes the component to re-render on every single state change regardless of relevance. Always pass a selector function that picks exactly the value the component needs.
All Selectors Live in useStoreSelectors.ts
Every named selector — useIsAuthenticated, useCurrentUser, useAuthLoading — must be defined in the dedicated selectors file and exported from there. Components import from this file, not directly from the store. This centralizes all selector logic and makes refactoring easier.
Actions Are Safe to Destructure Together
Action functions are stable references and do not cause re-renders. It is acceptable to select a group of actions together in one selector. State values must always be selected individually.
Use Shallow Comparison for Object Selections
If a selector must return an object with multiple fields, use shallow from Zustand as the equality function. Without this, the component re-renders even when the object's values haven't changed because a new object reference is returned on every call.
Immer Middleware Rules
Use Immer Only When State is Nested Two or More Levels Deep
For flat state with primitive values, use regular set(). Apply Immer middleware to stores where deeply nested objects need to be updated — user profiles with nested preferences, addresses, or settings. Immer is not needed for simple top-level assignments.
Never Return and Mutate in the Same Immer Callback
Inside an Immer set() callback, either mutate the draft directly or return a new object — never both. Mixing the two causes Immer to throw. Choose mutation style for nested updates, return style for complete state replacements.
Always Guard Against Null Before Mutating Nested Objects
Before mutating a nested object inside an Immer callback, always check that the parent exists. If the parent is null and the action tries to mutate a child property, Immer will throw a runtime error. Defensive null checks are mandatory.
Persist Middleware Rules
Only Persist What Is Truly Necessary
Never persist entire store state by default. Always use partialize to explicitly whitelist fields that should survive a page refresh. Persisting loading states, error messages, or transient UI values is a bug.
Always Name the Storage Key Explicitly
The name field in persist config is the localStorage key. It must be explicit, unique, and human-readable — for example auth-storage or theme-preferences. Never use auto-generated or ambiguous names.
Persist Tokens, Not Sensitive Data
Authentication tokens may be persisted in localStorage for session continuity. Passwords, raw API responses, or personally identifiable information must never be persisted. When in doubt, do not persist.
Slice Pattern Rules
Use Slices Only for Large Multi-Domain Stores
The slice pattern — combining multiple StateCreator functions into one bound store — is appropriate when a single store legitimately crosses domains, such as a checkout flow that needs both cart state and auth state together. For typical apps, separate stores are preferred over slices.
Define All Slice Types in a Shared Types File
Every slice interface must be defined in slices/types.ts. The combined AppStore type that merges all slice interfaces also lives there. Individual slice files import from types — they never redefine their own interfaces inline.
Each Slice Is Responsible Only for Its Own State
A slice action must never directly mutate another slice's state. If coordination is needed between slices, it must happen at the bound store level through composition, not by cross-referencing slice internals.
Reset and Cleanup Rules
Export a resetAllStores() Function from stores/index.ts
The index.ts file must export a single resetAllStores() function that calls .getState().reset() on every store. This function is called in exactly one place — the logout handler. It is never called from components directly.
Subscriptions Created Outside React Must Be Unsubscribed
If store.subscribe() is used outside a React component — for example to attach an auth token to an API client — the returned unsubscribe function must be stored and called during application teardown. Leaking subscriptions causes stale callbacks.
Component Integration Rules
Components Are Consumers Only
A component's only job with respect to state is to read values via selectors and call actions on user interaction. Components must not derive new state, filter arrays, or compute totals inline. That logic belongs in the store or in a dedicated selector.
Co-locate Loading and Error State with Their Data
Whenever a component renders async data, it must also handle the corresponding isLoading and error states from the same store. Rendering data without handling its loading and error states is incomplete.
Clear Errors Explicitly
After an async action fails and the error is displayed, always provide a mechanism — a dismiss button, a timeout, or a navigation event — to call clearError(). Errors must not persist in state indefinitely.
Anti-Patterns — Never Do These
Final Checklist for AI Initialization
- One store file created per domain concern
- Every store has
initialStateconst defined above the store - Every store has a
reset()action returninginitialState - Every store is wrapped with
devtools()middleware and named - All async actions handle
pending,fulfilled, andrejectedphases - All selectors are defined in
useStoreSelectors.ts - No component subscribes to the full store object
-
resetAllStores()is exported fromstores/index.ts - Persist middleware uses
partializeto whitelist only necessary fields - Immer middleware applied only to stores with nested state
- All store interfaces separate state fields from action fields
- No async logic lives inside React components
