# Zustand Production Patterns: JWT Auth + UI State Management (2025) **Comprehensive Research for React + Vite + FastAPI Production Template** --- ## Table of Contents 1. [Executive Summary](#executive-summary) 2. [Zustand v4+ Fundamentals](#zustand-v4-fundamentals) 3. [JWT Token Storage: The 2025 Consensus](#jwt-token-storage-the-2025-consensus) 4. [Auth Store Architecture](#auth-store-architecture) 5. [Token Refresh Patterns](#token-refresh-patterns) 6. [UI State Stores (Per-Page Patterns)](#ui-state-stores-per-page-patterns) 7. [Persistence Middleware Deep Dive](#persistence-middleware-deep-dive) 8. [Selectors & Performance Optimization](#selectors--performance-optimization) 9. [Form State Management](#form-state-management) 10. [Cross-Tab Synchronization](#cross-tab-synchronization) 11. [Protected Routes Integration](#protected-routes-integration) 12. [TypeScript Patterns](#typescript-patterns) 13. [Middleware Combinations](#middleware-combinations) 14. [Testing Strategies](#testing-strategies) 15. [Security Best Practices](#security-best-practices) 16. [Anti-Patterns to Avoid](#anti-patterns-to-avoid) 17. [Production Checklist](#production-checklist) --- ## Executive Summary ### The 2025 Consensus for JWT Storage in SPAs **Short Answer:** Access token in **memory** (React state/Zustand), refresh token in **httpOnly cookie**. **Why this matters:** - Access tokens in localStorage = XSS vulnerable - Both tokens in httpOnly cookies = CSRF vulnerable + can't set `Authorization` header - Memory-only = lost on refresh (bad UX) - **The hybrid approach** balances security and UX ### Key Architectural Decisions 1. **Auth Store**: Store access token in Zustand (memory), use httpOnly cookies for refresh token 2. **UI Stores**: Per-page stores for UI state with selective persistence 3. **Form State**: Use Zustand for drafts, React Hook Form for validation 4. **Token Refresh**: Axios interceptors on 401, with request queue pattern 5. **Persistence**: `partialize` to exclude sensitive data, version for migrations 6. **Selectors**: Use `useShallow` for multiple selections, atomic selectors for primitives 7. **Cross-Tab**: BroadcastChannel or localStorage events for sync --- ## Zustand v4+ Fundamentals ### What's New in v4+ **Major Changes from v3:** - **Curried create syntax**: `create()(...)` for better TypeScript inference - **Improved middleware typing**: Automatic type inference for middleware chains - **`useShallow` hook**: Replaces `shallow` import for React components - **Vanilla store separation**: `createStore` for vanilla JS, `create` for React - **Better devtools integration**: Enhanced Redux DevTools support ### Basic Store Creation (v4 Pattern) ```typescript // src/core/lib/auth.store.ts import { create } from 'zustand' interface AuthState { accessToken: string | null user: User | null isAuthenticated: boolean } interface AuthActions { setTokens: (token: string, user: User) => void clearAuth: () => void } type AuthStore = AuthState & AuthActions // v4 curried syntax for better TypeScript inference export const useAuthStore = create()((set) => ({ // State accessToken: null, user: null, isAuthenticated: false, // Actions setTokens: (token, user) => set({ accessToken: token, user, isAuthenticated: true }), clearAuth: () => set({ accessToken: null, user: null, isAuthenticated: false }), })) ``` ### Slice Pattern for Large Stores ```typescript // src/pages/Dashboard/stores/slices/dashboard-ui.slice.ts import { StateCreator } from 'zustand' interface DashboardUISlice { isSidebarOpen: boolean activeModal: 'create' | 'edit' | null toggleSidebar: () => void openModal: (type: 'create' | 'edit') => void closeModal: () => void } export const createDashboardUISlice: StateCreator< DashboardUISlice, [], [], DashboardUISlice > = (set) => ({ isSidebarOpen: true, activeModal: null, toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })), openModal: (type) => set({ activeModal: type }), closeModal: () => set({ activeModal: null }), }) // Combine slices import { create } from 'zustand' import { createDashboardUISlice } from './slices/dashboard-ui.slice' export const useDashboardStore = create()((...a) => ({ ...createDashboardUISlice(...a), })) ``` --- ## JWT Token Storage: The 2025 Consensus ### The Security Landscape **localStorage/sessionStorage:** - ❌ **Vulnerable to XSS** - Any malicious script can read tokens - ✅ Easy to implement - ✅ Persists across refreshes **httpOnly Cookies:** - ✅ **Immune to XSS** - JavaScript cannot read the cookie - ❌ Vulnerable to CSRF (mitigated with SameSite=Strict) - ❌ Cannot set `Authorization: Bearer` header from client - ✅ Automatically sent with requests **Memory Only (React State/Zustand):** - ✅ **Immune to XSS** - No persistence layer to attack - ❌ Lost on page refresh (bad UX) - ❌ Lost on new tab (bad UX) ### The 2025 Best Practice: Hybrid Approach ``` ┌─────────────────────────────────────────┐ │ CLIENT (Browser) │ │ │ │ ┌─────────────────┐ ┌──────────────┐ │ │ │ Zustand Store │ │ httpOnly │ │ │ │ (Memory) │ │ Cookie │ │ │ │ │ │ │ │ │ │ accessToken ✓ │ │ refreshToken │ │ │ │ user data ✓ │ │ (server-set) │ │ │ └─────────────────┘ └──────────────┘ │ │ │ └─────────────────────────────────────────┘ │ │ │ Bearer {access} │ Cookie (auto) ▼ ▼ ┌─────────────────────────────────────────┐ │ SERVER (FastAPI) │ │ │ │ POST /auth/login │ │ → Returns: { accessToken, user } │ │ → Sets: refreshToken in httpOnly cookie│ │ │ │ GET /auth/refresh │ │ → Reads: refreshToken from cookie │ │ → Returns: { accessToken } │ └─────────────────────────────────────────┘ ``` ### Implementation: Auth Store ```typescript // src/core/lib/auth.store.ts import { create } from 'zustand' import { devtools } from 'zustand/middleware' interface User { id: string email: string name: string role: string } interface AuthState { // Access token stored in memory accessToken: string | null user: User | null isAuthenticated: boolean isLoading: boolean } interface AuthActions { setAuth: (token: string, user: User) => void clearAuth: () => void refreshAccessToken: () => Promise setLoading: (loading: boolean) => void } type AuthStore = AuthState & AuthActions export const useAuthStore = create()( devtools( (set, get) => ({ // State accessToken: null, user: null, isAuthenticated: false, isLoading: true, // Actions setAuth: (token, user) => set({ accessToken: token, user, isAuthenticated: true, isLoading: false }, false, 'auth/setAuth'), clearAuth: () => set({ accessToken: null, user: null, isAuthenticated: false, isLoading: false }, false, 'auth/clearAuth'), refreshAccessToken: async () => { try { // Call refresh endpoint (refresh token sent via httpOnly cookie) const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include', // Important: sends cookies }) if (!response.ok) { throw new Error('Refresh failed') } const { accessToken, user } = await response.json() get().setAuth(accessToken, user) } catch (error) { get().clearAuth() throw error } }, setLoading: (loading) => set({ isLoading: loading }), }), { name: 'AuthStore' } ) ) // Convenient selectors export const selectAccessToken = (state: AuthStore) => state.accessToken export const selectUser = (state: AuthStore) => state.user export const selectIsAuthenticated = (state: AuthStore) => state.isAuthenticated export const selectIsLoading = (state: AuthStore) => state.isLoading ``` ### Why This Works 1. **Access Token in Memory**: - Used for API requests via `Authorization: Bearer {token}` - XSS attacks can't steal it from localStorage - Lost on refresh, but we use refresh token to get a new one 2. **Refresh Token in httpOnly Cookie**: - Set by server: `Set-Cookie: refreshToken=...; HttpOnly; Secure; SameSite=Strict` - JavaScript cannot read it (XSS protection) - CSRF mitigated by SameSite=Strict - Automatically sent on `/auth/refresh` requests 3. **Page Refresh Flow**: ``` User refreshes page → Access token lost (Zustand memory cleared) → App calls /auth/refresh on mount → Browser automatically sends refreshToken cookie → Server returns new accessToken → Store in Zustand ``` ### Security Considerations **XSS Protection:** - ✅ Access token not in localStorage = safe from XSS - ✅ Refresh token not readable by JS = safe from XSS - ⚠️ Still need CSP headers and input sanitization **CSRF Protection:** - ✅ SameSite=Strict prevents cross-origin requests with cookies - ✅ Access token uses `Authorization` header (not cookies) = CSRF immune - ⚠️ For older browsers, implement CSRF token pattern **Token Expiry:** - Access token: Short-lived (5-15 minutes) - Refresh token: Longer-lived (7-30 days) - Implement token rotation on refresh --- ## Auth Store Architecture ### Full Production Auth Store ```typescript // src/core/lib/auth.store.ts import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { api } from '@/core/api/client' interface User { id: string email: string name: string role: 'admin' | 'user' permissions: string[] } interface AuthState { accessToken: string | null user: User | null isAuthenticated: boolean isLoading: boolean error: string | null } interface AuthActions { // Core auth actions setAuth: (token: string, user: User) => void clearAuth: () => void // Login/Logout login: (email: string, password: string) => Promise logout: () => Promise // Token management refreshAccessToken: () => Promise // Utility setLoading: (loading: boolean) => void setError: (error: string | null) => void checkAuth: () => Promise } type AuthStore = AuthState & AuthActions export const useAuthStore = create()( devtools( (set, get) => ({ // Initial State accessToken: null, user: null, isAuthenticated: false, isLoading: true, error: null, // Core Actions setAuth: (token, user) => set( { accessToken: token, user, isAuthenticated: true, isLoading: false, error: null }, false, 'auth/setAuth' ), clearAuth: () => set( { accessToken: null, user: null, isAuthenticated: false, isLoading: false, error: null }, false, 'auth/clearAuth' ), // Login login: async (email, password) => { set({ isLoading: true, error: null }) try { // Server sets refreshToken as httpOnly cookie const { accessToken, user } = await api.post('/auth/login', { email, password }) get().setAuth(accessToken, user) } catch (error) { const message = error instanceof Error ? error.message : 'Login failed' set({ error: message, isLoading: false }) throw error } }, // Logout logout: async () => { try { // Server clears refreshToken cookie await api.post('/auth/logout') } finally { get().clearAuth() } }, // Refresh Token refreshAccessToken: async () => { try { // refreshToken sent automatically via httpOnly cookie const { accessToken, user } = await api.post('/auth/refresh') get().setAuth(accessToken, user) } catch (error) { get().clearAuth() throw error } }, // Check Auth on App Mount checkAuth: async () => { set({ isLoading: true }) try { await get().refreshAccessToken() } catch { get().clearAuth() } }, // Utility setLoading: (loading) => set({ isLoading: loading }), setError: (error) => set({ error }), }), { name: 'AuthStore' } ) ) // Selectors export const selectAuth = (state: AuthStore) => ({ accessToken: state.accessToken, user: state.user, isAuthenticated: state.isAuthenticated, }) export const selectIsLoading = (state: AuthStore) => state.isLoading export const selectUser = (state: AuthStore) => state.user export const selectHasRole = (role: string) => (state: AuthStore) => state.user?.role === role export const selectHasPermission = (permission: string) => (state: AuthStore) => state.user?.permissions.includes(permission) ``` ### FastAPI Backend Integration ```python # backend/app/routes/auth.py from fastapi import APIRouter, Response, Depends, HTTPException from fastapi.security import HTTPBearer from datetime import datetime, timedelta import jwt router = APIRouter(prefix="/auth", tags=["auth"]) security = HTTPBearer() @router.post("/login") async def login( credentials: LoginCredentials, response: Response ): # Validate credentials user = await authenticate_user(credentials.email, credentials.password) if not user: raise HTTPException(401, "Invalid credentials") # Generate tokens access_token = create_access_token(user.id, expires_delta=timedelta(minutes=15)) refresh_token = create_refresh_token(user.id, expires_delta=timedelta(days=30)) # Set refresh token as httpOnly cookie response.set_cookie( key="refreshToken", value=refresh_token, httponly=True, secure=True, # HTTPS only samesite="strict", # CSRF protection max_age=30 * 24 * 60 * 60 # 30 days ) # Return access token in response body return { "accessToken": access_token, "user": user.to_dict() } @router.post("/refresh") async def refresh(request: Request): # Extract refresh token from cookie refresh_token = request.cookies.get("refreshToken") if not refresh_token: raise HTTPException(401, "No refresh token") try: # Validate refresh token payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=["HS256"]) user_id = payload["sub"] user = await get_user(user_id) # Generate new access token access_token = create_access_token(user.id, expires_delta=timedelta(minutes=15)) return { "accessToken": access_token, "user": user.to_dict() } except jwt.ExpiredSignatureError: raise HTTPException(401, "Refresh token expired") except jwt.InvalidTokenError: raise HTTPException(401, "Invalid refresh token") @router.post("/logout") async def logout(response: Response): # Clear refresh token cookie response.delete_cookie( key="refreshToken", httponly=True, secure=True, samesite="strict" ) return {"message": "Logged out"} ``` --- ## Token Refresh Patterns ### Axios Interceptor Setup ```typescript // src/core/api/client.ts import axios from 'axios' import { useAuthStore } from '@/core/lib/auth.store' // Create axios instance export const api = axios.create({ baseURL: import.meta.env.VITE_API_URL, withCredentials: true, // Important: sends cookies }) // Request queue for handling concurrent requests during token refresh let isRefreshing = false let failedQueue: Array<{ resolve: (value?: any) => void reject: (reason?: any) => void }> = [] const processQueue = (error: any, token: string | null = null) => { failedQueue.forEach(prom => { if (error) { prom.reject(error) } else { prom.resolve(token) } }) failedQueue = [] } // Request interceptor: Add access token to headers api.interceptors.request.use( (config) => { const token = useAuthStore.getState().accessToken if (token && config.headers) { config.headers.Authorization = `Bearer ${token}` } return config }, (error) => Promise.reject(error) ) // Response interceptor: Handle 401 and refresh token api.interceptors.response.use( (response) => response.data, // Return data directly async (error) => { const originalRequest = error.config // If error is not 401 or request already retried, reject if (error.response?.status !== 401 || originalRequest._retry) { return Promise.reject(error) } // If token refresh is already in progress, queue this request if (isRefreshing) { return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }) }) .then((token) => { originalRequest.headers.Authorization = `Bearer ${token}` return api(originalRequest) }) .catch((err) => Promise.reject(err)) } // Mark request as retried originalRequest._retry = true isRefreshing = true try { // Attempt to refresh token await useAuthStore.getState().refreshAccessToken() const newToken = useAuthStore.getState().accessToken // Process queued requests processQueue(null, newToken) // Retry original request with new token originalRequest.headers.Authorization = `Bearer ${newToken}` return api(originalRequest) } catch (refreshError) { // Refresh failed - clear auth and reject all queued requests processQueue(refreshError, null) useAuthStore.getState().clearAuth() // Redirect to login window.location.href = '/login' return Promise.reject(refreshError) } finally { isRefreshing = false } } ) ``` ### Alternative: Proactive Token Refresh ```typescript // src/core/api/token-refresh.ts import { useAuthStore } from '@/core/lib/auth.store' import { jwtDecode } from 'jwt-decode' interface JwtPayload { exp: number iat: number sub: string } // Start background token refresh export function startTokenRefreshTimer() { const checkAndRefresh = async () => { const { accessToken, refreshAccessToken } = useAuthStore.getState() if (!accessToken) return try { const decoded = jwtDecode(accessToken) const expiresAt = decoded.exp * 1000 // Convert to ms const now = Date.now() const timeUntilExpiry = expiresAt - now // Refresh if token expires in less than 2 minutes if (timeUntilExpiry < 2 * 60 * 1000) { await refreshAccessToken() } } catch (error) { console.error('Token refresh check failed:', error) } } // Check every minute setInterval(checkAndRefresh, 60 * 1000) // Also check immediately checkAndRefresh() } // In App.tsx import { startTokenRefreshTimer } from '@/core/api/token-refresh' function App() { useEffect(() => { const timer = startTokenRefreshTimer() return () => clearInterval(timer) }, []) // ... } ``` ### Request Queue Pattern Explained **Why we need it:** When an access token expires, multiple API requests might fail with 401 simultaneously. Without a queue, each request would try to refresh the token, causing race conditions. **How it works:** 1. First 401 triggers refresh, sets `isRefreshing = true` 2. Subsequent 401s are queued in `failedQueue` 3. After successful refresh, all queued requests are retried with new token 4. If refresh fails, all queued requests are rejected **Visual Flow:** ``` Request 1 (401) ─┐ Request 2 (401) ─┼─→ Queued ─→ Wait for refresh Request 3 (401) ─┘ │ ├─ isRefreshing = true ├─ Call /auth/refresh ├─ Get new accessToken ├─ Process queue with new token └─ isRefreshing = false │ ├─→ Request 1 retried ✓ ├─→ Request 2 retried ✓ └─→ Request 3 retried ✓ ``` --- ## UI State Stores (Per-Page Patterns) ### When to Create a UI Store **Create a per-page UI store when:** - UI state needs persistence across refreshes (sidebar open/closed, view mode) - State is shared across multiple components in that page - State is complex (modals, multi-step forms, filters) **Don't create a store when:** - State is local to one component (use `useState`) - State doesn't need persistence - State is server-derived (use TanStack Query) ### Dashboard UI Store Example ```typescript // src/pages/Dashboard/stores/dashboard-ui.store.ts import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' import { devtools } from 'zustand/middleware' interface DashboardUIState { // Sidebar isSidebarOpen: boolean sidebarWidth: number // Modals activeModal: 'create' | 'edit' | 'delete' | null modalData: any | null // View preferences viewMode: 'grid' | 'list' sortBy: 'name' | 'date' | 'size' sortOrder: 'asc' | 'desc' // Filters filters: { status: string[] tags: string[] dateRange: { start: string; end: string } | null } // Selection selectedItems: Set } interface DashboardUIActions { // Sidebar toggleSidebar: () => void setSidebarWidth: (width: number) => void // Modals openModal: (type: 'create' | 'edit' | 'delete', data?: any) => void closeModal: () => void // View setViewMode: (mode: 'grid' | 'list') => void setSortBy: (sortBy: string, order: 'asc' | 'desc') => void // Filters setFilter: (key: keyof DashboardUIState['filters'], value: any) => void clearFilters: () => void // Selection selectItem: (id: string) => void deselectItem: (id: string) => void clearSelection: () => void selectAll: (ids: string[]) => void } type DashboardUIStore = DashboardUIState & DashboardUIActions export const useDashboardUIStore = create()( devtools( persist( (set, get) => ({ // State isSidebarOpen: true, sidebarWidth: 280, activeModal: null, modalData: null, viewMode: 'grid', sortBy: 'date', sortOrder: 'desc', filters: { status: [], tags: [], dateRange: null, }, selectedItems: new Set(), // Actions toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })), setSidebarWidth: (width) => set({ sidebarWidth: width }), openModal: (type, data = null) => set({ activeModal: type, modalData: data }), closeModal: () => set({ activeModal: null, modalData: null }), setViewMode: (mode) => set({ viewMode: mode }), setSortBy: (sortBy, order) => set({ sortBy: sortBy as any, sortOrder: order }), setFilter: (key, value) => set((state) => ({ filters: { ...state.filters, [key]: value } })), clearFilters: () => set({ filters: { status: [], tags: [], dateRange: null, } }), selectItem: (id) => set((state) => { const newSet = new Set(state.selectedItems) newSet.add(id) return { selectedItems: newSet } }), deselectItem: (id) => set((state) => { const newSet = new Set(state.selectedItems) newSet.delete(id) return { selectedItems: newSet } }), clearSelection: () => set({ selectedItems: new Set() }), selectAll: (ids) => set({ selectedItems: new Set(ids) }), }), { name: 'dashboard-ui-storage', storage: createJSONStorage(() => localStorage), // Only persist certain fields partialize: (state) => ({ isSidebarOpen: state.isSidebarOpen, sidebarWidth: state.sidebarWidth, viewMode: state.viewMode, sortBy: state.sortBy, sortOrder: state.sortOrder, // Don't persist: modals, selection, filters (ephemeral) }), // Custom serialization for Set serialize: (state) => { return JSON.stringify({ state: { ...state.state, selectedItems: Array.from(state.state.selectedItems) } }) }, deserialize: (str) => { const parsed = JSON.parse(str) return { state: { ...parsed.state, selectedItems: new Set(parsed.state.selectedItems || []) } } }, } ), { name: 'DashboardUI' } ) ) // Selectors export const selectSidebar = (state: DashboardUIStore) => ({ isOpen: state.isSidebarOpen, width: state.sidebarWidth, }) export const selectModal = (state: DashboardUIStore) => ({ type: state.activeModal, data: state.modalData, }) export const selectView = (state: DashboardUIStore) => ({ mode: state.viewMode, sortBy: state.sortBy, sortOrder: state.sortOrder, }) ``` ### Usage in Components ```typescript // src/pages/Dashboard/components/Sidebar.tsx import { useDashboardUIStore } from '../stores/dashboard-ui.store' import { useShallow } from 'zustand/react/shallow' function Sidebar() { // Efficient: Only re-renders when these values change const { isOpen, width } = useDashboardUIStore( useShallow((state) => ({ isOpen: state.isSidebarOpen, width: state.sidebarWidth, })) ) const toggleSidebar = useDashboardUIStore((state) => state.toggleSidebar) return ( ) } ``` --- ## Persistence Middleware Deep Dive ### partialize: Selective Persistence **Rule:** Never persist functions, only persist necessary state. ```typescript import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' interface StoreState { // Persist theme: 'light' | 'dark' preferences: { fontSize: number } // Don't persist (ephemeral) isModalOpen: boolean currentPage: number } const useStore = create()( persist( (set) => ({ theme: 'light', preferences: { fontSize: 14 }, isModalOpen: false, currentPage: 1, // actions... }), { name: 'app-settings', // Method 1: Whitelist specific keys partialize: (state) => ({ theme: state.theme, preferences: state.preferences, }), // Method 2: Blacklist keys (filter out unwanted) // partialize: (state) => // Object.fromEntries( // Object.entries(state).filter(([key]) => // !['isModalOpen', 'currentPage'].includes(key) // ) // ), } ) ) ``` ### Version Management & Migration ```typescript import { create } from 'zustand' import { persist } from 'zustand/middleware' interface StoreV2 { version: 2 settings: { theme: 'light' | 'dark' | 'auto' // Added 'auto' locale: string // Added locale } } const useStore = create()( persist( (set) => ({ version: 2, settings: { theme: 'light', locale: 'en', }, }), { name: 'app-store', version: 2, // Current version // Migration function migrate: (persistedState: any, version: number) => { if (version === 1) { // Migrate from v1 to v2 return { version: 2, settings: { theme: persistedState.theme || 'light', locale: 'en', // New field with default }, } } return persistedState }, // Called if migration fails onRehydrateStorage: () => (state, error) => { if (error) { console.error('Hydration failed:', error) // Could reset to defaults here } }, } ) ) ``` ### Storage Options ```typescript // localStorage (default) - persists forever storage: createJSONStorage(() => localStorage) // sessionStorage - cleared on tab close storage: createJSONStorage(() => sessionStorage) // IndexedDB - for large data import { get, set, del } from 'idb-keyval' storage: { getItem: async (name) => { return (await get(name)) || null }, setItem: async (name, value) => { await set(name, value) }, removeItem: async (name) => { await del(name) }, } // Custom encryption import CryptoJS from 'crypto-js' const SECRET_KEY = import.meta.env.VITE_STORAGE_KEY storage: { getItem: (name) => { const encrypted = localStorage.getItem(name) if (!encrypted) return null try { const decrypted = CryptoJS.AES.decrypt(encrypted, SECRET_KEY).toString( CryptoJS.enc.Utf8 ) return decrypted } catch { return null } }, setItem: (name, value) => { const encrypted = CryptoJS.AES.encrypt(value, SECRET_KEY).toString() localStorage.setItem(name, encrypted) }, removeItem: (name) => localStorage.removeItem(name), } ``` ### Handling Storage Quota ```typescript import { persist } from 'zustand/middleware' persist( (set) => ({ /* store */ }), { name: 'app-store', onRehydrateStorage: () => (state, error) => { if (error) { // Check if quota exceeded if (error.name === 'QuotaExceededError') { console.error('Storage quota exceeded') // Strategy 1: Clear old data const storeNames = ['old-store-1', 'old-store-2'] storeNames.forEach(name => localStorage.removeItem(name)) // Strategy 2: Compress data // Strategy 3: Move to IndexedDB } } }, } ) ``` --- ## Selectors & Performance Optimization ### The Golden Rule **Atomic selectors** for primitives = ✅ Efficient **Object selectors** without `useShallow` = ❌ Re-renders on every store update ### Atomic Selectors (Recommended) ```typescript // ✅ GOOD: Atomic selectors function Component() { // Only re-renders when count changes const count = useStore((state) => state.count) // Only re-renders when increment changes (never, it's a function) const increment = useStore((state) => state.increment) return } ``` ### Object Selectors with useShallow ```typescript import { useShallow } from 'zustand/react/shallow' // ✅ GOOD: Object selector with useShallow function Component() { const { count, text, increment } = useStore( useShallow((state) => ({ count: state.count, text: state.text, increment: state.increment, })) ) return (

{count} - {text}

) } ``` ### Array Selectors ```typescript // ✅ GOOD: Array selector with useShallow const [nuts, honey] = useStore( useShallow((state) => [state.nuts, state.honey]) ) // ✅ GOOD: Computed array with useShallow const names = useStore( useShallow((state) => Object.keys(state.users)) ) ``` ### Anti-Pattern: Subscribing to Entire Store ```typescript // ❌ BAD: Re-renders on ANY state change const state = useStore() // ❌ BAD: Same problem const { count, text, increment } = useStore((state) => ({ count: state.count, text: state.text, increment: state.increment, })) // Missing useShallow means new object every render! ``` ### Custom Selectors for Reusability ```typescript // src/pages/Dashboard/stores/dashboard-ui.store.ts // Export selectors with the store export const selectSidebar = (state: DashboardUIStore) => ({ isOpen: state.isSidebarOpen, width: state.sidebarWidth, }) export const selectFilters = (state: DashboardUIStore) => state.filters export const selectHasSelection = (state: DashboardUIStore) => state.selectedItems.size > 0 // Usage import { useDashboardUIStore, selectSidebar } from '../stores/dashboard-ui.store' import { useShallow } from 'zustand/react/shallow' function Sidebar() { const sidebar = useDashboardUIStore(useShallow(selectSidebar)) // ... } ``` ### Derived State / Computed Values ```typescript // Method 1: Compute in selector const totalPrice = useStore((state) => state.cart.reduce((sum, item) => sum + item.price * item.quantity, 0) ) // Method 2: Memoize with useMemo const totalPrice = useMemo( () => cart.reduce((sum, item) => sum + item.price * item.quantity, 0), [cart] ) // Method 3: Store computed values (if expensive) interface StoreState { cart: CartItem[] _totalPrice: number // Cached computed value addToCart: (item: CartItem) => void } const useStore = create((set) => ({ cart: [], _totalPrice: 0, addToCart: (item) => set((state) => { const newCart = [...state.cart, item] const newTotal = newCart.reduce( (sum, i) => sum + i.price * i.quantity, 0 ) return { cart: newCart, _totalPrice: newTotal, } }), })) ``` ### Performance Monitoring ```typescript // Detect unnecessary re-renders function Component() { const renderCount = useRef(0) renderCount.current++ console.log(`Component rendered ${renderCount.current} times`) const count = useStore((state) => state.count) return
{count}
} // React DevTools Profiler // Use React DevTools to identify components that re-render too often ``` --- ## Form State Management ### When to Use Zustand for Forms **Use Zustand when:** - ✅ Draft persistence across refreshes - ✅ Multi-step forms with state across pages - ✅ Form state shared across components - ✅ Auto-save functionality **Use React Hook Form when:** - ✅ Form validation - ✅ Field-level errors - ✅ Controlled inputs - ✅ Schema validation (Zod, Yup) ### The Hybrid Approach (Recommended) ```typescript // src/pages/Dashboard/stores/form-drafts.store.ts import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' interface FormDraft { formId: string data: Record lastSaved: number } interface FormDraftsState { drafts: Record } interface FormDraftsActions { saveDraft: (formId: string, data: Record) => void loadDraft: (formId: string) => FormDraft | null deleteDraft: (formId: string) => void clearOldDrafts: (maxAgeMs: number) => void } type FormDraftsStore = FormDraftsState & FormDraftsActions export const useFormDraftsStore = create()( persist( (set, get) => ({ drafts: {}, saveDraft: (formId, data) => set((state) => ({ drafts: { ...state.drafts, [formId]: { formId, data, lastSaved: Date.now(), }, }, })), loadDraft: (formId) => { return get().drafts[formId] || null }, deleteDraft: (formId) => set((state) => { const newDrafts = { ...state.drafts } delete newDrafts[formId] return { drafts: newDrafts } }), clearOldDrafts: (maxAgeMs) => set((state) => { const now = Date.now() const newDrafts = Object.fromEntries( Object.entries(state.drafts).filter( ([_, draft]) => now - draft.lastSaved < maxAgeMs ) ) return { drafts: newDrafts } }), }), { name: 'form-drafts-storage', storage: createJSONStorage(() => localStorage), } ) ) ``` ### Form Component with React Hook Form + Zustand ```typescript // src/pages/Dashboard/components/CreateForm.tsx import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { useFormDraftsStore } from '../stores/form-drafts.store' import { useEffect } from 'react' import { useDebouncedCallback } from 'use-debounce' const formSchema = z.object({ title: z.string().min(1, 'Title required'), description: z.string().min(10, 'Description too short'), tags: z.array(z.string()), }) type FormData = z.infer function CreateForm() { const FORM_ID = 'create-item-form' const { saveDraft, loadDraft, deleteDraft } = useFormDraftsStore() const { register, handleSubmit, watch, reset, formState: { errors }, } = useForm({ resolver: zodResolver(formSchema), defaultValues: () => { // Load draft on mount const draft = loadDraft(FORM_ID) return draft?.data || { title: '', description: '', tags: [], } }, }) // Auto-save draft on form changes (debounced) const formData = watch() const saveDraftDebounced = useDebouncedCallback( (data: FormData) => { saveDraft(FORM_ID, data) console.log('Draft saved') }, 1000 // Save after 1 second of no typing ) useEffect(() => { saveDraftDebounced(formData) }, [formData, saveDraftDebounced]) // Submit form const onSubmit = async (data: FormData) => { try { await api.post('/items', data) // Clear draft on successful submit deleteDraft(FORM_ID) reset() toast.success('Item created!') } catch (error) { toast.error('Failed to create item') } } return (
{errors.title && {errors.title.message}}