State Management
State Management Architecture
Project: {{PROJECT_NAME}} Version: {{VERSION}} Date: {{DATE}} Author: {{AUTHOR}} Status: Draft | In Review | Approved Reviewers: {{REVIEWERS}}
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | {{DATE}} | {{AUTHOR}} | Initial draft |
1. State Architecture Overview
{{PROJECT_NAME}} uses a layered state management approach:
| Layer | Library | Scope |
|---|---|---|
| Server state | {{TanStack Query / SWR / Apollo}} |
API data, caching, synchronization |
| Client / UI state | {{Zustand / Redux Toolkit / Pinia}} |
Application-wide UI state |
| URL state | Native router APIs | Filters, pagination, search |
| Form state | {{React Hook Form / Formik / VeeValidate}} |
Form data, validation |
| Persistent state | {{localStorage / cookies}} |
User preferences, tokens |
Guiding principle: Server state is NOT stored in client state. API data lives in the query cache — client state holds only UI concerns (sidebar open, selected theme, modal visibility).
2. Data Flow Diagram
flowchart TD
User["User Interaction"] --> Component["Component"]
Component -->|"API call"| QueryCache["Query Cache\n(TanStack Query)"]
Component -->|"UI action"| ClientStore["Client Store\n(Zustand)"]
Component -->|"Navigate"| URLState["URL State\n(Router)"]
Component -->|"Form input"| FormState["Form State\n(RHF)"]
QueryCache -->|"fetch / mutation"| API["Backend API"]
QueryCache -->|"cached data"| Component
ClientStore -->|"state slice"| Component
URLState -->|"params"| Component
FormState -->|"values / errors"| Component
ClientStore -->|"user prefs"| LocalStorage["localStorage\n(Persistent)"]
LocalStorage -->|"hydrate on load"| ClientStore
3. State Categories
3.1 Server State (API Data)
Library: {{TanStack Query v5}}
Configuration:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes garbage collection
retry: 2,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
mutations: {
retry: 0,
},
},
});
Query key convention:
// Hierarchical keys for precise invalidation
['users'] // all user queries
['users', { page: 1, search: '' }] // paginated user list
['users', userId] // single user
['users', userId, 'posts'] // user's posts
Stale time per resource type:
| Resource | Stale Time | GC Time | Rationale |
|---|---|---|---|
| User profile | 5 min | 30 min | Changes infrequently |
| Dashboard stats | 30 sec | 5 min | Near-realtime |
| Config / enums | 1 hour | 24 hours | Rarely changes |
| Notifications | 0 (always fresh) | 2 min | Time-sensitive |
3.2 Client State (UI State)
Library: {{Zustand}}
Store slices:
| Slice | File | Responsibility |
|---|---|---|
uiStore |
src/stores/ui.store.ts |
Sidebar open, active modal, theme |
authStore |
src/stores/auth.store.ts |
Current user, roles, session token |
notificationStore |
src/stores/notification.store.ts |
Toast queue, unread count |
{{featureStore}} |
src/stores/{{feature}}.store.ts |
{{Feature-specific UI state}} |
Slice template:
// src/stores/ui.store.ts
import { create } from 'zustand';
interface UIState {
sidebarOpen: boolean;
activeModal: string | null;
theme: 'light' | 'dark' | 'system';
}
interface UIActions {
setSidebarOpen: (open: boolean) => void;
openModal: (id: string) => void;
closeModal: () => void;
setTheme: (theme: UIState['theme']) => void;
}
export const useUIStore = create<UIState & UIActions>((set) => ({
sidebarOpen: true,
activeModal: null,
theme: 'system',
setSidebarOpen: (open) => set({ sidebarOpen: open }),
openModal: (id) => set({ activeModal: id }),
closeModal: () => set({ activeModal: null }),
setTheme: (theme) => set({ theme }),
}));
3.3 URL State
URL state is the source of truth for:
- Search / filter queries
- Pagination (page, pageSize)
- Sort column and direction
- Active tab / view mode
- Modal ID (when deep-linkable)
Convention:
/users?page=2&pageSize=25&search=john&sort=name&dir=asc&status=active
Library: Native URLSearchParams + router useSearchParams hook
Serialization helper: src/lib/url-state.ts
// Type-safe URL param parsing with fallbacks
export function parseListParams(params: URLSearchParams): ListParams {
return {
page: Number(params.get('page') ?? 1),
pageSize: Number(params.get('pageSize') ?? 25),
search: params.get('search') ?? '',
sort: params.get('sort') ?? 'createdAt',
dir: (params.get('dir') as 'asc' | 'desc') ?? 'desc',
};
}
3.4 Form State
Library: {{React Hook Form v7}}
Validation: {{Zod}}
Pattern:
const schema = z.object({
email: z.string().email('Invalid email'),
name: z.string().min(2, 'Name must be at least 2 characters'),
});
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { email: '', name: '' },
});
Rules:
- Form state NEVER leaks into global store
- Schema validation lives in
src/schemas/— reused for API validation - Complex multi-step forms use form context + Stepper component
3.5 Persistent State
| Data | Storage | Library | Encryption |
|---|---|---|---|
| Theme preference | localStorage |
Zustand persist middleware | No |
| Sidebar collapsed | localStorage |
Zustand persist middleware | No |
| Language preference | localStorage |
Native | No |
| Auth token | httpOnly cookie |
Server-set | Yes (TLS) |
| Refresh token | httpOnly cookie |
Server-set | Yes (TLS) |
RULE: Auth tokens NEVER in localStorage. HttpOnly cookies only.
Zustand persistence example:
import { persist } from 'zustand/middleware';
export const usePrefsStore = create(
persist<PrefsState>(
(set) => ({ theme: 'system', /* ... */ }),
{ name: 'user-preferences' }
)
);
4. Caching Strategy
4.1 Optimistic Updates
// Optimistic update pattern with rollback
const mutation = useMutation({
mutationFn: updateUser,
onMutate: async (newData) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['users', userId] });
// Snapshot current state for rollback
const snapshot = queryClient.getQueryData(['users', userId]);
// Optimistically update cache
queryClient.setQueryData(['users', userId], (old) => ({ ...old, ...newData }));
return { snapshot };
},
onError: (err, vars, context) => {
// Rollback on error
queryClient.setQueryData(['users', userId], context?.snapshot);
},
onSettled: () => {
// Always refetch to sync with server
queryClient.invalidateQueries({ queryKey: ['users', userId] });
},
});
4.2 Cache Invalidation Rules
| Mutation | Invalidates |
|---|---|
| Create user | ['users'] (list) |
| Update user | ['users', userId] |
| Delete user | ['users'] (list) |
| Update user role | ['users', userId], ['permissions'] |
5. Real-Time State
Protocol: {{WebSocket | Server-Sent Events | None}}
Library: {{Socket.io | native WebSocket | @microsoft/signalr}}
Pattern — WebSocket to Query Cache:
// On incoming WS event, update query cache directly
socket.on('user.updated', (user: User) => {
queryClient.setQueryData(['users', user.id], user);
queryClient.invalidateQueries({ queryKey: ['users'], exact: false });
});
Connection management:
- Reconnect with exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
- Show "reconnecting" banner in UI after 5s disconnect
- Batch updates: max 50ms batching window to prevent UI thrashing
TODO: Document specific WebSocket event schema — reference event-schema-documentation.md.
6. Hydration Strategy (SSR ↔ Client)
Approach: {{Dehydrate/Hydrate (TanStack Query) | getServerSideProps | prefetchQuery}}
// Server: prefetch and dehydrate
export async function getServerSideProps() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
return {
props: { dehydratedState: dehydrate(queryClient) },
};
}
// Client: hydrate — no refetch until staleTime expires
export default function Page({ dehydratedState }) {
return (
<HydrationBoundary state={dehydratedState}>
<UserList />
</HydrationBoundary>
);
}
Rule: Prefetch all critical page data on server. No loading spinners on initial navigation.
7. State Debugging Tools
| Tool | Usage | Enabled In |
|---|---|---|
| TanStack Query Devtools | Inspect cache, queries, mutations | Dev only |
| Zustand devtools middleware | Redux DevTools integration | Dev only |
| React DevTools | Component state tree | Dev only |
| Redux DevTools Extension | If using Redux | Dev only |
Setup:
// Devtools enabled only in development
const devtools = process.env.NODE_ENV === 'development'
? (await import('zustand/middleware')).devtools
: (f: any) => f;
8. Performance Considerations
8.1 Selector Pattern (Zustand)
// BAD — subscribes to entire store, re-renders on any change
const { sidebarOpen, theme } = useUIStore();
// GOOD — subscribe to only what the component needs
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const theme = useUIStore((s) => s.theme);
8.2 Memoization Rules
| Scenario | Tool | When to Use |
|---|---|---|
| Expensive derived data | useMemo |
Only if profiling shows issue |
| Stable callback refs | useCallback |
Only if passed to memoized child |
| Stable component output | React.memo |
Only if parent re-renders frequently |
Rule: Do NOT pre-emptively memoize. Profile first, optimize second.
8.3 Query Deduplication
TanStack Query automatically deduplicates identical queries rendered simultaneously. No additional work needed.
TODO: Run React Profiler on critical paths and document findings.
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | |||
| Frontend Lead | |||
| Tech Lead |
No comments to display
No comments to display