Skip to Content
Client LayerState Management

State Management

All client state is managed via Zustand stores. Four stores serve distinct concerns.

Store Overview

StoreFilePersistdevtoolsConcern
useChatStorestore/chat-store.tsYes (chat-storage)YesConversations, messages, streaming
useUserStorestore/user-store.tsYes (user-storage)YesCurrent user, sessions, preferences
useAdminStorestore/admin-store.tsNoYesAdmin stats, user list, resources
useNotificationStorestore/notification-store.tsYes (notification-storage)NoIn-app notifications

useChatStore

interface ChatStoreState { conversations: ConversationSummary[] activeConversationId: string | null messages: Record<string, ChatMessage[]> // keyed by conversation ID activeMessageId: Record<string, string | null> // leaf node per conversation nextCursor: string | null // pagination cursor totalConversationsCount: number freeMessageCount: number // for unauthenticated users useAiTitleGeneration: boolean isLoadingConversations: boolean isLoadingMessages: boolean isSendingMessage: boolean isTyping: boolean error: string | null abortController: AbortController | null }

Persistence: messages, conversations, activeConversationId, freeMessageCount, and useAiTitleGeneration are persisted. In-flight state (isLoading*, abortController) is excluded.

Race condition guard: selectConversation() checks isSendingMessage before loading messages for a different conversation — prevents switching conversations mid-stream.

useUserStore

interface UserStoreState { user: UserResponse | null sessions: DashboardSession[] preferences: UserPreferences activeDashboardView: DashboardView // Persisted dashboard tab } interface UserPreferences { theme: 'system' | 'light' | 'dark' fontSize: 'small' | 'medium' | 'large' notificationsEnabled: boolean emailNotifications: boolean pushNotifications: boolean }

401 handling: fetchUser() catches 401 responses and sets user = null, effectively signing the user out without throwing. This makes the store safe to call on app boot.

Dashboard Persistence: The activeDashboardView is stored in the user store and persisted via user-storage. This ensures that the selected tab (Overview, Admin, Settings, etc.) is remembered across page refreshes without requiring URL parameters.

Session management: fetchSessions() loads all active sessions; revokeSession(sessionId) calls DELETE /user/revoke-session/{sessionId} and removes the entry from local state immediately (optimistic update).

useAdminStore

No persistence — admin data is always fetched fresh on dashboard load.

interface AdminStats { total_users: number active_users_today: number total_conversations: number total_messages: number total_resources: number // ... }

Pagination:

  • Users: offset-based ({ total, limit, offset })
  • Resources: cursor-based ({ total, cursor })

All actions validate responses with Zod schemas (AdminStatsSchema, UserListResponseSchema, etc.) before writing to state. Zod parse failures surface as error state rather than crashing.

useNotificationStore

interface Notification { id: string type: 'system' | 'conversation' | 'security' | 'admin' title: string message: string timestamp: number read: boolean }

Notifications are purely client-local — there is no server-side notification endpoint. They are generated programmatically by other store actions (e.g., admin actions produce admin type notifications; errors produce system type).

Context → Store Delegation

The AdminProvider context is a thin wrapper over useAdminStore:

// context/admin-context.tsx const value: AdminContextValue = { isAdmin: user?.role === 'admin', isLoading, error: adminStore.error, fetchStats: adminStore.fetchStats, fetchUsers: adminStore.fetchUsers, // ... full delegation }

This pattern keeps Zustand stores as the single source of truth while allowing context wrappers to inject role guards (isAdmin, isContributor) computed from the authenticated user.

Last updated on