# State Management Guide - Zustand + TanStack Query ## Document Overview This guide explains how to effectively combine Zustand (client state) and TanStack Query v5 (server state) in ColaFlow's frontend architecture, with complete implementation examples. **Key Principle**: Zustand manages authentication and UI state, TanStack Query manages all API data. --- ## Table of Contents 1. [Architecture Overview](#architecture-overview) 2. [Zustand Stores](#zustand-stores) 3. [TanStack Query Setup](#tanstack-query-setup) 4. [Custom Hooks](#custom-hooks) 5. [State Synchronization](#state-synchronization) 6. [Best Practices](#best-practices) 7. [Performance Optimization](#performance-optimization) --- ## Architecture Overview ### State Classification | State Type | Technology | Persistence | Example | |------------|------------|-------------|---------| | **Authentication** | Zustand | Session Storage | User info, tenant info, access token | | **UI State** | Zustand | Local Storage | Sidebar open/closed, theme, language | | **Server Data** | TanStack Query | Memory (cache) | Projects, issues, MCP tokens | | **Form State** | React Hook Form | Component local | Login form, create token form | ### Data Flow ``` User Action ↓ Component (UI Layer) ↓ ├──→ Zustand Store (for auth/UI state) │ ├── setUser() │ ├── clearAuth() │ └── toggleSidebar() │ └──→ TanStack Query Hook (for API data) ├── useLogin() → authService.login() ├── useMcpTokens() → mcpService.listTokens() └── useCreateMcpToken() → mcpService.createToken() ↓ API Client (Axios) ↓ Backend API (.NET 9) ``` --- ## Zustand Stores ### 1. Auth Store **File**: `stores/useAuthStore.ts` ```typescript import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; // Types export interface User { id: string; email: string; fullName: string; role: 'Admin' | 'Member' | 'Viewer'; authProvider: 'Local' | 'AzureAD' | 'Google' | 'Okta'; externalUserId?: string; avatarUrl?: string; } export interface Tenant { id: string; slug: string; name: string; plan: 'Free' | 'Starter' | 'Professional' | 'Enterprise'; status: 'Active' | 'Trial' | 'Suspended' | 'Cancelled'; ssoEnabled: boolean; ssoProvider?: 'AzureAD' | 'Google' | 'Okta' | 'GenericSaml'; } interface AuthState { // State user: User | null; tenant: Tenant | null; accessToken: string | null; isAuthenticated: boolean; isLoading: boolean; // Actions setUser: (user: User, tenant: Tenant, accessToken: string) => void; setAccessToken: (token: string) => void; clearAuth: () => void; setLoading: (loading: boolean) => void; } export const useAuthStore = create()( persist( (set) => ({ // Initial state user: null, tenant: null, accessToken: null, isAuthenticated: false, isLoading: false, // Actions setUser: (user, tenant, accessToken) => { set({ user, tenant, accessToken, isAuthenticated: true, isLoading: false, }); }, setAccessToken: (accessToken) => { set({ accessToken }); }, clearAuth: () => { set({ user: null, tenant: null, accessToken: null, isAuthenticated: false, isLoading: false, }); }, setLoading: (isLoading) => { set({ isLoading }); }, }), { name: 'colaflow-auth', // Key in storage storage: createJSONStorage(() => sessionStorage), // Use sessionStorage partialize: (state) => ({ // Only persist user and tenant info, NOT the token user: state.user, tenant: state.tenant, // Do NOT persist accessToken (security) }), } ) ); // Selectors (for performance optimization) export const useUser = () => useAuthStore((state) => state.user); export const useTenant = () => useAuthStore((state) => state.tenant); export const useIsAuthenticated = () => useAuthStore((state) => state.isAuthenticated); ``` **Usage**: ```tsx // In a component import { useAuthStore, useUser, useTenant } from '@/stores/useAuthStore'; function UserProfile() { // Method 1: Select specific fields (recommended for performance) const user = useUser(); const tenant = useTenant(); // Method 2: Select multiple fields const { user, tenant } = useAuthStore((state) => ({ user: state.user, tenant: state.tenant, })); // Method 3: Access actions const clearAuth = useAuthStore((state) => state.clearAuth); return (

{user?.fullName}

{tenant?.name}

); } ``` --- ### 2. UI Store **File**: `stores/useUiStore.ts` ```typescript import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; interface UiState { // Sidebar sidebarOpen: boolean; toggleSidebar: () => void; setSidebarOpen: (open: boolean) => void; // Theme theme: 'light' | 'dark' | 'system'; setTheme: (theme: 'light' | 'dark' | 'system') => void; // Language language: 'en' | 'zh-CN'; setLanguage: (language: 'en' | 'zh-CN') => void; // Notifications notificationCount: number; setNotificationCount: (count: number) => void; } export const useUiStore = create()( persist( (set) => ({ // Sidebar sidebarOpen: true, toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), setSidebarOpen: (open) => set({ sidebarOpen: open }), // Theme theme: 'system', setTheme: (theme) => set({ theme }), // Language language: 'en', setLanguage: (language) => set({ language }), // Notifications notificationCount: 0, setNotificationCount: (notificationCount) => set({ notificationCount }), }), { name: 'colaflow-ui', storage: createJSONStorage(() => localStorage), // Persist UI preferences } ) ); // Selectors export const useSidebarOpen = () => useUiStore((state) => state.sidebarOpen); export const useTheme = () => useUiStore((state) => state.theme); ``` --- ## TanStack Query Setup ### Query Client Configuration **File**: `lib/query-client.ts` ```typescript import { QueryClient, DefaultOptions } from '@tanstack/react-query'; const queryConfig: DefaultOptions = { queries: { // Stale time: Data is considered fresh for 5 minutes staleTime: 1000 * 60 * 5, // Garbage collection time: Unused data is garbage collected after 10 minutes gcTime: 1000 * 60 * 10, // Renamed from cacheTime in v5 // Refetch on window focus refetchOnWindowFocus: false, // Retry failed requests retry: 1, // Retry delay retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), }, mutations: { // Retry failed mutations retry: 0, }, }; export const queryClient = new QueryClient({ defaultOptions: queryConfig, }); ``` **Provider Setup**: **File**: `app/layout.tsx` ```typescript 'use client'; import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { queryClient } from '@/lib/query-client'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} {/* DevTools (only in development) */} {process.env.NODE_ENV === 'development' && ( )} ); } ``` --- ## Custom Hooks ### Authentication Hooks #### useLogin **File**: `hooks/auth/useLogin.ts` ```typescript import { useMutation, useQueryClient } from '@tanstack/react-query'; import { authService } from '@/services/auth.service'; import { useAuthStore } from '@/stores/useAuthStore'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; export function useLogin() { const setUser = useAuthStore((state) => state.setUser); const router = useRouter(); const queryClient = useQueryClient(); return useMutation({ mutationFn: authService.login, onSuccess: (data) => { // Store user, tenant, and token in Zustand setUser(data.user, data.tenant, data.accessToken); // Show success message toast.success(`Welcome back, ${data.user.fullName}!`); // Prefetch user data (optional) queryClient.prefetchQuery({ queryKey: ['user', data.user.id], queryFn: () => authService.getCurrentUser(), }); // Redirect to dashboard or original page const redirectUrl = sessionStorage.getItem('redirect_after_login') || '/dashboard'; sessionStorage.removeItem('redirect_after_login'); router.push(redirectUrl); }, onError: (error: any) => { // Show error message const message = error.response?.data?.message || 'Login failed. Please try again.'; toast.error(message); }, }); } ``` **Usage**: ```tsx // In a login component import { useLogin } from '@/hooks/auth/useLogin'; function LoginForm() { const login = useLogin(); const handleSubmit = (data: { email: string; password: string }) => { login.mutate(data); }; return (
{/* Form fields */}
); } ``` --- #### useLogout **File**: `hooks/auth/useLogout.ts` ```typescript import { useMutation, useQueryClient } from '@tanstack/react-query'; import { authService } from '@/services/auth.service'; import { useAuthStore } from '@/stores/useAuthStore'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; export function useLogout() { const clearAuth = useAuthStore((state) => state.clearAuth); const router = useRouter(); const queryClient = useQueryClient(); return useMutation({ mutationFn: authService.logout, onSuccess: () => { // Clear auth state clearAuth(); // Clear all query cache queryClient.clear(); // Show success message toast.success('Logged out successfully'); // Redirect to login page router.push('/login'); }, onError: () => { // Even if logout fails on backend, clear client state clearAuth(); queryClient.clear(); router.push('/login'); }, }); } ``` --- ### Tenant Hooks #### useCheckSlug **File**: `hooks/tenants/useCheckSlug.ts` ```typescript import { useQuery } from '@tanstack/react-query'; import { tenantService } from '@/services/tenant.service'; import { useMemo } from 'react'; export function useCheckSlug(slug: string, enabled: boolean = true) { // Trim and lowercase const normalizedSlug = slug.trim().toLowerCase(); return useQuery({ queryKey: ['check-slug', normalizedSlug], queryFn: () => tenantService.checkSlugAvailability(normalizedSlug), enabled: enabled && normalizedSlug.length >= 3, staleTime: 5000, // Cache for 5 seconds retry: false, // Don't retry slug checks }); } ``` **Usage**: ```tsx import { useCheckSlug } from '@/hooks/tenants/useCheckSlug'; function TenantSlugInput() { const [slug, setSlug] = useState(''); const { data, isLoading, error } = useCheckSlug(slug, slug.length >= 3); return (
setSlug(e.target.value)} placeholder="your-company" /> {isLoading && Checking...} {data?.available === true && ( ✓ Available )} {data?.available === false && ( ✗ Taken )}
); } ``` --- #### useSsoConfig **File**: `hooks/tenants/useSsoConfig.ts` ```typescript import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { tenantService } from '@/services/tenant.service'; import { useTenant } from '@/stores/useAuthStore'; import { toast } from 'sonner'; // Get SSO config export function useSsoConfig() { const tenant = useTenant(); return useQuery({ queryKey: ['sso-config', tenant?.id], queryFn: () => tenantService.getSsoConfig(tenant!.id), enabled: !!tenant, staleTime: 1000 * 60 * 10, // 10 minutes }); } // Update SSO config export function useUpdateSsoConfig() { const tenant = useTenant(); const queryClient = useQueryClient(); return useMutation({ mutationFn: (config: any) => tenantService.updateSsoConfig(tenant!.id, config), onSuccess: () => { // Invalidate SSO config query queryClient.invalidateQueries({ queryKey: ['sso-config', tenant?.id] }); toast.success('SSO configuration updated successfully'); }, onError: (error: any) => { const message = error.response?.data?.message || 'Failed to update SSO configuration'; toast.error(message); }, }); } // Test SSO connection export function useTestSsoConnection() { const tenant = useTenant(); return useMutation({ mutationFn: () => tenantService.testSsoConnection(tenant!.id), onSuccess: (data) => { if (data.success) { toast.success('SSO connection successful!'); } else { toast.error(`Connection failed: ${data.message}`); } }, onError: () => { toast.error('Failed to test SSO connection'); }, }); } ``` --- ### MCP Token Hooks #### useMcpTokens **File**: `hooks/mcp/useMcpTokens.ts` ```typescript import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { mcpService } from '@/services/mcp.service'; import { toast } from 'sonner'; // List all tokens export function useMcpTokens() { return useQuery({ queryKey: ['mcp-tokens'], queryFn: mcpService.listTokens, staleTime: 1000 * 60 * 5, // 5 minutes }); } // Get single token details export function useMcpToken(tokenId: string) { return useQuery({ queryKey: ['mcp-token', tokenId], queryFn: () => mcpService.getToken(tokenId), enabled: !!tokenId, }); } // Create new token export function useCreateMcpToken() { const queryClient = useQueryClient(); return useMutation({ mutationFn: mcpService.createToken, onSuccess: () => { // Invalidate tokens list queryClient.invalidateQueries({ queryKey: ['mcp-tokens'] }); toast.success('MCP Token created successfully'); }, onError: (error: any) => { const message = error.response?.data?.message || 'Failed to create token'; toast.error(message); }, }); } // Revoke token export function useRevokeMcpToken() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ tokenId, reason }: { tokenId: string; reason?: string }) => mcpService.revokeToken(tokenId, reason), onSuccess: () => { // Invalidate tokens list queryClient.invalidateQueries({ queryKey: ['mcp-tokens'] }); toast.success('Token revoked successfully'); }, onError: () => { toast.error('Failed to revoke token'); }, }); } // Get audit logs export function useMcpAuditLogs(tokenId: string, filters?: any) { return useQuery({ queryKey: ['mcp-audit-logs', tokenId, filters], queryFn: () => mcpService.getAuditLogs(tokenId, filters), enabled: !!tokenId, staleTime: 1000 * 60, // 1 minute }); } ``` --- ## State Synchronization ### Keeping AuthStore and Query Cache in Sync **Problem**: When user logs out, TanStack Query cache may still contain user-specific data. **Solution**: Clear query cache on logout. ```typescript // hooks/auth/useLogout.ts export function useLogout() { const clearAuth = useAuthStore((state) => state.clearAuth); const queryClient = useQueryClient(); return useMutation({ mutationFn: authService.logout, onSuccess: () => { // 1. Clear auth state clearAuth(); // 2. Clear ALL query cache queryClient.clear(); // Or selectively clear specific queries: // queryClient.removeQueries({ queryKey: ['projects'] }); // queryClient.removeQueries({ queryKey: ['issues'] }); // 3. Redirect router.push('/login'); }, }); } ``` --- ### Optimistic Updates **Use Case**: Immediately update UI before API call completes. **Example**: Revoke MCP token ```typescript // hooks/mcp/useMcpTokens.ts export function useRevokeMcpToken() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ tokenId, reason }: { tokenId: string; reason?: string }) => mcpService.revokeToken(tokenId, reason), // Optimistic update onMutate: async ({ tokenId }) => { // Cancel any outgoing refetches await queryClient.cancelQueries({ queryKey: ['mcp-tokens'] }); // Snapshot the previous value const previousTokens = queryClient.getQueryData(['mcp-tokens']); // Optimistically update to the new value queryClient.setQueryData(['mcp-tokens'], (old: any) => ({ ...old, tokens: old?.tokens.map((token: any) => token.id === tokenId ? { ...token, status: 'Revoked' } : token ), })); // Return context with previous value return { previousTokens }; }, // On error, roll back to previous value onError: (_err, _variables, context) => { if (context?.previousTokens) { queryClient.setQueryData(['mcp-tokens'], context.previousTokens); } toast.error('Failed to revoke token'); }, // Always refetch after success or error onSettled: () => { queryClient.invalidateQueries({ queryKey: ['mcp-tokens'] }); }, }); } ``` --- ## Best Practices ### 1. Use Query Keys Consistently **Good**: ```typescript // All related queries use the same base key queryKey: ['mcp-tokens'] queryKey: ['mcp-token', tokenId] queryKey: ['mcp-audit-logs', tokenId] // Invalidate all MCP token queries: queryClient.invalidateQueries({ queryKey: ['mcp-tokens'] }); queryClient.invalidateQueries({ queryKey: ['mcp-token'] }); ``` **Bad**: ```typescript // Inconsistent keys queryKey: ['tokens'] queryKey: ['mcpToken', tokenId] queryKey: ['audit', tokenId] ``` --- ### 2. Use Selectors for Performance **Good** (only re-renders when `user` changes): ```tsx const user = useAuthStore((state) => state.user); ``` **Bad** (re-renders when ANY auth state changes): ```tsx const { user } = useAuthStore(); ``` **Even Better** (dedicated selector): ```tsx // stores/useAuthStore.ts export const useUser = () => useAuthStore((state) => state.user); // In component const user = useUser(); ``` --- ### 3. Prefetch Critical Data ```typescript // app/(dashboard)/layout.tsx export default function DashboardLayout({ children }: { children: React.ReactNode }) { const queryClient = useQueryClient(); const user = useUser(); useEffect(() => { if (user) { // Prefetch projects queryClient.prefetchQuery({ queryKey: ['projects'], queryFn: projectService.getAll, }); // Prefetch issues queryClient.prefetchQuery({ queryKey: ['issues'], queryFn: issueService.getAll, }); } }, [user, queryClient]); return
{children}
; } ``` --- ### 4. Handle Loading and Error States ```tsx function ProjectList() { const { data, isLoading, error } = useProjects(); if (isLoading) { return ; } if (error) { return ( refetch()} /> ); } if (!data || data.length === 0) { return ; } return (
{data.map((project) => ( ))}
); } ``` --- ### 5. Use Mutations for Write Operations **Always use mutations** (not queries) for creating, updating, deleting data: ```typescript // ✅ Good const createProject = useMutation({ mutationFn: projectService.create, }); // ❌ Bad (don't use query for mutations) const createProject = useQuery({ queryKey: ['create-project'], queryFn: projectService.create, }); ``` --- ## Performance Optimization ### 1. Query Deduplication TanStack Query automatically deduplicates identical queries: ```tsx // Both components will share the same query function ComponentA() { const { data } = useMcpTokens(); // ... } function ComponentB() { const { data } = useMcpTokens(); // Same query, shared data // ... } ``` --- ### 2. Background Refetching Keep data fresh without blocking UI: ```typescript export function useMcpTokens() { return useQuery({ queryKey: ['mcp-tokens'], queryFn: mcpService.listTokens, staleTime: 1000 * 60 * 5, // 5 minutes // Refetch in background when data is stale refetchOnMount: true, refetchOnReconnect: true, refetchOnWindowFocus: false, // Disable for better UX }); } ``` --- ### 3. Pagination ```typescript export function useMcpAuditLogs(tokenId: string, page: number, limit: number) { return useQuery({ queryKey: ['mcp-audit-logs', tokenId, page, limit], queryFn: () => mcpService.getAuditLogs(tokenId, { page, limit }), enabled: !!tokenId, // Keep previous data while fetching next page placeholderData: (previousData) => previousData, }); } ``` **Usage**: ```tsx function AuditLogTable({ tokenId }: { tokenId: string }) { const [page, setPage] = useState(1); const { data, isLoading, isPlaceholderData } = useMcpAuditLogs(tokenId, page, 50); return (
); } ``` --- ### 4. Infinite Queries For infinite scroll: ```typescript import { useInfiniteQuery } from '@tanstack/react-query'; export function useInfiniteAuditLogs(tokenId: string) { return useInfiniteQuery({ queryKey: ['mcp-audit-logs-infinite', tokenId], queryFn: ({ pageParam = 1 }) => mcpService.getAuditLogs(tokenId, { page: pageParam, limit: 50 }), getNextPageParam: (lastPage) => { const { page, totalPages } = lastPage.pagination; return page < totalPages ? page + 1 : undefined; }, enabled: !!tokenId, }); } ``` **Usage**: ```tsx function InfiniteAuditLogList({ tokenId }: { tokenId: string }) { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteAuditLogs(tokenId); return (
{data?.pages.map((page) => page.logs.map((log) => ) )} {hasNextPage && ( )}
); } ``` --- ## Conclusion This state management guide demonstrates how to effectively combine Zustand and TanStack Query in ColaFlow's frontend: **Key Takeaways**: - ✅ Use Zustand for authentication and UI state - ✅ Use TanStack Query for all server data - ✅ Clear query cache on logout - ✅ Use optimistic updates for instant UX - ✅ Use selectors for performance - ✅ Prefetch critical data - ✅ Handle loading/error states gracefully - ✅ Use mutations for write operations **Next Document**: `component-library.md` (Reusable components catalog)