AgentSkillsCN

state-management-guidelines

在应用中管理状态时(客户端状态、服务器状态、离线支持)

SKILL.md
--- frontmatter
name: state-management-guidelines
description: when managing state in the application (client state, server state, offline support)

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 TypeSolution
API data (todos, users, etc.)React Query
User preferences (theme, offline)Zustand (features/settings)
Auth hintsZustand (features/auth)
Route persistenceZustand (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 statesrc/client/features/{name}/store.ts
  • Route-specific state (only used by one route) → src/client/routes/{RouteName}/store.ts

Steps:

  1. Create store file using createStore
  2. Choose: persistOptions (persisted) OR inMemoryOnly: true (in-memory)
  3. For feature stores: Create index.ts and export from src/client/features/index.ts
  4. For route stores: Import directly within the route folder
  5. 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';