State Management Guidelines
📚 Full Documentation: docs/state-management.md 📚 React Query Mutations: docs/react-query-mutations.md 📚 Store Factory: docs/zustand-stores.md
Quick Decision
| State Type | Solution |
|---|---|
| API data (todos, users, etc.) | React Query |
| User preferences (theme, offline) | Zustand (features/settings) |
| Auth hints | Zustand (features/auth) |
| Route persistence | Zustand (features/router) |
| Ephemeral UI (modal, form input) | useState |
code
API data? → React Query Persist across restarts? → Zustand (createStore) Otherwise → useState
Zustand Store Factory (REQUIRED)
All Zustand stores MUST use createStore from @/client/stores:
typescript
import { createStore } from '@/client/stores';
// PERSISTED store (default) - persistOptions REQUIRED
const useMyStore = createStore<MyState>({
key: 'my-storage',
label: 'My Store',
creator: (set) => ({ ... }),
persistOptions: { partialize: (state) => ({ ... }) },
});
// IN-MEMORY store (explicit opt-out) - inMemoryOnly REQUIRED
const useModalStore = createStore<ModalState>({
key: 'modal',
label: 'Modal',
inMemoryOnly: true,
creator: (set) => ({ ... }),
});
Direct zustand imports are BLOCKED by ESLint outside src/client/stores/.
Zustand Imports
typescript
import { useUser, useIsProbablyLoggedIn } from '@/client/features/auth';
import { useSettingsStore, useEffectiveOffline } from '@/client/features/settings';
import { useRouteStore } from '@/client/features/router';
React Query Hooks
typescript
// Query: always use useQueryDefaults()
export function useTodos() {
const queryDefaults = useQueryDefaults();
return useQuery({
queryKey: ['todos'],
queryFn: () => fetchTodos(),
...queryDefaults,
});
}
🚨 CRITICAL: Optimistic-Only Mutation Pattern
NEVER update UI from server responses on SUCCESS. Only rollback on ERROR.
This prevents race conditions when user clicks faster than server responds.
typescript
// ✅ CORRECT: Optimistic-only pattern
useMutation({
mutationFn: async (data) => {
const response = await apiClient.post('entity/update', data);
if (response.data?.error) throw new Error(response.data.error);
return response.data;
},
// UPDATE UI IMMEDIATELY - this is the source of truth
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: ['entity'] });
const previous = queryClient.getQueryData(['entity']);
queryClient.setQueryData(['entity'], (old) => ({ ...old, ...variables }));
return { previous };
},
// ONLY on error: rollback
onError: (_error, _variables, context) => {
if (context?.previous) {
queryClient.setQueryData(['entity'], context.previous);
}
},
// onSuccess: EMPTY - never update from server response
// onSettled: EMPTY - never invalidateQueries (causes race conditions)
});
typescript
// ❌ WRONG: These cause race conditions!
onSuccess: (data) => {
queryClient.setQueryData(['entity'], data); // Updates from stale server response
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['entity'] }); // Triggers refetch, overwrites optimistic
},
📚 Full Documentation: docs/offline-pwa-support.md
Config Defaults
All TTL/cache values in src/client/config/defaults.ts:
typescript
import { TIME, STORE_DEFAULTS, QUERY_DEFAULTS } from '@/client/config';
New Store Checklist
Store Location:
- •Cross-route state →
src/client/features/{name}/store.ts - •Route-specific state (only used by one route) →
src/client/routes/{RouteName}/store.ts
Steps:
- •Create store file using
createStore - •Choose:
persistOptions(persisted) ORinMemoryOnly: true(in-memory) - •For feature stores: Create
index.tsand export fromsrc/client/features/index.ts - •For route stores: Import directly within the route folder
- •For TTL validation: use
createTTLValidator(STORE_DEFAULTS.TTL)
Rule: If the store is only imported by files within a single route, keep it in that route folder.
Registry Utilities
typescript
import {
getAllStores,
getTotalCacheSize,
clearAllPersistedStores
} from '@/client/stores';