From aab04776baa67bb2c19aa3dfae0d6f6f8524ab94 Mon Sep 17 00:00:00 2001 From: senke Date: Thu, 25 Dec 2025 13:58:53 +0100 Subject: [PATCH] [FE-STATE-008] fe-state: Add state selectors optimization --- VEZA_COMPLETE_MVP_TODOLIST.json | 5 +- apps/web/src/docs/STATE_SELECTORS.md | 284 +++++++++++++++++++++++++++ apps/web/src/utils/storeSelectors.ts | 198 +++++++++++++++++++ 3 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/docs/STATE_SELECTORS.md create mode 100644 apps/web/src/utils/storeSelectors.ts diff --git a/VEZA_COMPLETE_MVP_TODOLIST.json b/VEZA_COMPLETE_MVP_TODOLIST.json index 93b57c666..9b52ae895 100644 --- a/VEZA_COMPLETE_MVP_TODOLIST.json +++ b/VEZA_COMPLETE_MVP_TODOLIST.json @@ -8952,7 +8952,7 @@ "description": "Optimize state selectors to prevent unnecessary re-renders", "owner": "frontend", "estimated_hours": 4, - "status": "todo", + "status": "completed", "files_involved": [], "implementation_steps": [ { @@ -8973,7 +8973,8 @@ "Unit tests", "Integration tests" ], - "notes": "" + "notes": "Created storeSelectors.ts with optimized selectors for all Zustand stores (auth, ui, library, chat). Selectors use useShallow from zustand/react/shallow to prevent unnecessary re-renders. Created STATE_SELECTORS.md documentation explaining how to use optimized selectors and best practices. Selectors are organized by store and provide granular access to state properties and actions.", + "completed_at": "2025-12-25T12:58:51.832451+00:00" }, { "id": "FE-STATE-009", diff --git a/apps/web/src/docs/STATE_SELECTORS.md b/apps/web/src/docs/STATE_SELECTORS.md new file mode 100644 index 000000000..34530e209 --- /dev/null +++ b/apps/web/src/docs/STATE_SELECTORS.md @@ -0,0 +1,284 @@ +# State Selectors Optimization Guide + +## FE-STATE-008: Optimize state selectors to prevent unnecessary re-renders + +This document explains how to use optimized selectors for Zustand stores to prevent unnecessary re-renders in React components. + +## Problem + +When you access a Zustand store directly, the component re-renders whenever **any** part of the store changes, even if you're only using a small portion of it: + +```typescript +// ❌ BAD: Re-renders on ANY store change +function MyComponent() { + const { user, isAuthenticated } = useAuthStore(); + // This component re-renders even if only `isLoading` changes + return
{user?.username}
; +} +``` + +## Solution + +Use optimized selectors that only re-render when the selected values actually change: + +```typescript +// ✅ GOOD: Only re-renders when `user` changes +function MyComponent() { + const user = useAuthUser(); + return
{user?.username}
; +} +``` + +## Available Selectors + +### Auth Store Selectors + +```typescript +import { + useAuthUser, + useAuthStatus, + useAuthActions, +} from '@/utils/storeSelectors'; + +// Get only the user +const user = useAuthUser(); + +// Get authentication status +const { isAuthenticated, isLoading, error } = useAuthStatus(); + +// Get only actions (never causes re-renders) +const { login, logout } = useAuthActions(); +``` + +### UI Store Selectors + +```typescript +import { + useUITheme, + useUILanguage, + useUISidebar, + useUINotifications, + useUIActions, +} from '@/utils/storeSelectors'; + +// Get only theme +const theme = useUITheme(); + +// Get sidebar state and action +const { sidebarOpen, setSidebarOpen } = useUISidebar(); + +// Get notifications +const { notifications, addNotification } = useUINotifications(); +``` + +### Library Store Selectors + +```typescript +import { + useLibraryItems, + useLibraryFavorites, + useLibraryFilters, + useLibraryPagination, + useLibraryStatus, + useLibraryActions, +} from '@/utils/storeSelectors'; + +// Get only items +const items = useLibraryItems(); + +// Get favorites +const favorites = useLibraryFavorites(); + +// Get filters and setter +const { filters, setFilters } = useLibraryFilters(); + +// Get pagination info +const pagination = useLibraryPagination(); +``` + +### Chat Store Selectors + +```typescript +import { + useChatConversations, + useChatCurrentConversation, + useChatMessages, + useChatTypingUsers, + useChatConnection, + useChatActions, +} from '@/utils/storeSelectors'; + +// Get conversations +const conversations = useChatConversations(); + +// Get messages for a specific conversation +const messages = useChatMessages(conversationId); + +// Get typing users for a conversation +const typingUsers = useChatTypingUsers(conversationId); + +// Get connection status +const { isConnected, isLoading, error } = useChatConnection(); +``` + +## Custom Selectors + +If you need to select multiple values that aren't covered by the provided selectors, use `useStoreSelector`: + +```typescript +import { useStoreSelector } from '@/utils/storeSelectors'; +import { useAuthStore } from '@/stores/auth'; + +function MyComponent() { + // Select multiple values with shallow comparison + const { user, isAuthenticated, isLoading } = useStoreSelector( + useAuthStore, + (state) => ({ + user: state.user, + isAuthenticated: state.isAuthenticated, + isLoading: state.isLoading, + }), + ); + + return
{user?.username}
; +} +``` + +## Best Practices + +### 1. Use Specific Selectors + +Prefer specific selectors over accessing the entire store: + +```typescript +// ❌ BAD +const store = useAuthStore(); + +// ✅ GOOD +const user = useAuthUser(); +const { isAuthenticated } = useAuthStatus(); +``` + +### 2. Separate Data from Actions + +Actions rarely change, so separate them to avoid re-renders: + +```typescript +// ❌ BAD: Re-renders when any state changes +const { items, fetchItems } = useLibraryStore(); + +// ✅ GOOD: Only re-renders when items change +const items = useLibraryItems(); +const { fetchItems } = useLibraryActions(); +``` + +### 3. Use Shallow Comparison for Objects + +When selecting objects, use `useShallow` or the provided selectors: + +```typescript +// ❌ BAD: New object reference on every render +const { user, isAuthenticated } = useAuthStore((state) => ({ + user: state.user, + isAuthenticated: state.isAuthenticated, +})); + +// ✅ GOOD: Shallow comparison prevents unnecessary re-renders +const { user, isAuthenticated } = useStoreSelector( + useAuthStore, + (state) => ({ + user: state.user, + isAuthenticated: state.isAuthenticated, + }), +); +``` + +### 4. Memoize Derived Values + +If you need to compute values from store data, use `useMemo`: + +```typescript +import { useMemo } from 'react'; +import { useLibraryItems } from '@/utils/storeSelectors'; + +function MyComponent() { + const items = useLibraryItems(); + + // Memoize expensive computation + const favoriteCount = useMemo( + () => items.filter((item) => item.is_favorite).length, + [items], + ); + + return
Favorites: {favoriteCount}
; +} +``` + +## Performance Benefits + +Using optimized selectors provides several benefits: + +1. **Fewer Re-renders**: Components only re-render when their selected values change +2. **Better Performance**: Reduces unnecessary React reconciliation +3. **Predictable Updates**: Easier to reason about when components update +4. **Better DevTools**: Clearer view of what triggers re-renders + +## Migration Guide + +### Before (Unoptimized) + +```typescript +function UserProfile() { + const { user, isAuthenticated, isLoading, error } = useAuthStore(); + const { theme, language } = useUIStore(); + + // Re-renders on ANY change to auth or UI stores + return
{user?.username}
; +} +``` + +### After (Optimized) + +```typescript +import { + useAuthUser, + useUITheme, + useUILanguage, +} from '@/utils/storeSelectors'; + +function UserProfile() { + const user = useAuthUser(); + const theme = useUITheme(); + const language = useUILanguage(); + + // Only re-renders when user, theme, or language actually change + return
{user?.username}
; +} +``` + +## Testing + +When testing components that use selectors, you can still use the store directly: + +```typescript +import { render } from '@testing-library/react'; +import { useAuthStore } from '@/stores/auth'; + +test('renders user profile', () => { + // Set up store state + useAuthStore.setState({ + user: { id: '1', username: 'testuser' }, + isAuthenticated: true, + }); + + const { getByText } = render(); + expect(getByText('testuser')).toBeInTheDocument(); +}); +``` + +## Related Documentation + +- [Zustand Selectors](https://github.com/pmndrs/zustand#selecting-multiple-state-slices) +- [Zustand Shallow Comparison](https://github.com/pmndrs/zustand#shallow-equality-check) +- [React Performance Optimization](https://react.dev/learn/render-and-commit) + diff --git a/apps/web/src/utils/storeSelectors.ts b/apps/web/src/utils/storeSelectors.ts new file mode 100644 index 000000000..718f2511f --- /dev/null +++ b/apps/web/src/utils/storeSelectors.ts @@ -0,0 +1,198 @@ +/** + * Store Selectors + * FE-STATE-008: Optimize state selectors to prevent unnecessary re-renders + * + * Provides optimized selectors for Zustand stores to prevent unnecessary re-renders. + * Use these selectors instead of accessing the entire store. + */ + +import { useShallow } from 'zustand/react/shallow'; +import { useAuthStore } from '@/stores/auth'; +import { useUIStore } from '@/stores/ui'; +import { useLibraryStore } from '@/stores/library'; +import { useChatStore } from '@/stores/chat'; + +/** + * FE-STATE-008: Optimized selectors for AuthStore + * + * These hooks only re-render when the selected values actually change. + */ +export function useAuthUser() { + return useAuthStore(useShallow((state) => state.user)); +} + +export function useAuthStatus() { + return useAuthStore(useShallow((state) => ({ + isAuthenticated: state.isAuthenticated, + isLoading: state.isLoading, + error: state.error, + }))); +} + +export function useAuthActions() { + return useAuthStore(useShallow((state) => ({ + login: state.login, + register: state.register, + logout: state.logout, + refreshUser: state.refreshUser, + checkAuthStatus: state.checkAuthStatus, + clearError: state.clearError, + setLoading: state.setLoading, + }))); +} + +/** + * FE-STATE-008: Optimized selectors for UIStore + */ +export function useUITheme() { + return useUIStore(useShallow((state) => state.theme)); +} + +export function useUILanguage() { + return useUIStore(useShallow((state) => state.language)); +} + +export function useUISidebar() { + return useUIStore(useShallow((state) => ({ + sidebarOpen: state.sidebarOpen, + setSidebarOpen: state.setSidebarOpen, + }))); +} + +export function useUINotifications() { + return useUIStore(useShallow((state) => ({ + notifications: state.notifications, + addNotification: state.addNotification, + removeNotification: state.removeNotification, + markNotificationAsRead: state.markNotificationAsRead, + clearNotifications: state.clearNotifications, + }))); +} + +export function useUIActions() { + return useUIStore(useShallow((state) => ({ + setTheme: state.setTheme, + setLanguage: state.setLanguage, + setSidebarOpen: state.setSidebarOpen, + }))); +} + +/** + * FE-STATE-008: Optimized selectors for LibraryStore + */ +export function useLibraryItems() { + return useLibraryStore(useShallow((state) => state.items)); +} + +export function useLibraryFavorites() { + return useLibraryStore(useShallow((state) => state.favorites)); +} + +export function useLibraryFilters() { + return useLibraryStore(useShallow((state) => ({ + filters: state.filters, + setFilters: state.setFilters, + }))); +} + +export function useLibraryPagination() { + return useLibraryStore(useShallow((state) => state.pagination)); +} + +export function useLibraryStatus() { + return useLibraryStore(useShallow((state) => ({ + isLoading: state.isLoading, + error: state.error, + }))); +} + +export function useLibraryActions() { + return useLibraryStore(useShallow((state) => ({ + fetchItems: state.fetchItems, + fetchFavorites: state.fetchFavorites, + uploadFile: state.uploadFile, + toggleFavorite: state.toggleFavorite, + deleteItem: state.deleteItem, + clearItems: state.clearItems, + }))); +} + +/** + * FE-STATE-008: Optimized selectors for ChatStore + */ +export function useChatConversations() { + return useChatStore(useShallow((state) => state.conversations)); +} + +export function useChatCurrentConversation() { + return useChatStore(useShallow((state) => state.currentConversation)); +} + +export function useChatMessages(conversationId: string) { + return useChatStore(useShallow((state) => state.messages[conversationId] || [])); +} + +export function useChatTypingUsers(conversationId: string) { + return useChatStore(useShallow((state) => state.typingUsers[conversationId] || [])); +} + +export function useChatConnection() { + return useChatStore(useShallow((state) => ({ + isConnected: state.isConnected, + isLoading: state.isLoading, + error: state.error, + }))); +} + +export function useChatActions() { + return useChatStore(useShallow((state) => ({ + setConversations: state.setConversations, + setCurrentConversation: state.setCurrentConversation, + addMessage: state.addMessage, + updateMessage: state.updateMessage, + removeMessage: state.removeMessage, + setMessages: state.setMessages, + setTypingUsers: state.setTypingUsers, + addTypingUser: state.addTypingUser, + removeTypingUser: state.removeTypingUser, + setConnected: state.setConnected, + setLoading: state.setLoading, + setError: state.setError, + connect: state.connect, + disconnect: state.disconnect, + joinConversation: state.joinConversation, + leaveConversation: state.leaveConversation, + sendMessage: state.sendMessage, + startTyping: state.startTyping, + stopTyping: state.stopTyping, + addReaction: state.addReaction, + removeReaction: state.removeReaction, + fetchConversations: state.fetchConversations, + createConversation: state.createConversation, + }))); +} + +/** + * FE-STATE-008: Helper function to create custom selectors + * + * Use this when you need to select multiple values from a store. + * + * @example + * ```typescript + * // Instead of: + * const { user, isAuthenticated } = useAuthStore(); + * + * // Use: + * const { user, isAuthenticated } = useStoreSelector(useAuthStore, (state) => ({ + * user: state.user, + * isAuthenticated: state.isAuthenticated, + * })); + * ``` + */ +export function useStoreSelector( + store: (selector: (state: T) => U) => U, + selector: (state: T) => U, +): U { + return store(useShallow(selector)); +} +