[FE-STATE-008] fe-state: Add state selectors optimization
This commit is contained in:
parent
e9a3d9084b
commit
aab04776ba
3 changed files with 485 additions and 2 deletions
|
|
@ -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",
|
||||
|
|
|
|||
284
apps/web/src/docs/STATE_SELECTORS.md
Normal file
284
apps/web/src/docs/STATE_SELECTORS.md
Normal file
|
|
@ -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 <div>{user?.username}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## 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 <div>{user?.username}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## 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 <div>{user?.username}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## 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 <div>Favorites: {favoriteCount}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## 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 <div>{user?.username}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### 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 <div>{user?.username}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## 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(<UserProfile />);
|
||||
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)
|
||||
|
||||
198
apps/web/src/utils/storeSelectors.ts
Normal file
198
apps/web/src/utils/storeSelectors.ts
Normal file
|
|
@ -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<T, U>(
|
||||
store: (selector: (state: T) => U) => U,
|
||||
selector: (state: T) => U,
|
||||
): U {
|
||||
return store(useShallow(selector));
|
||||
}
|
||||
|
||||
Loading…
Reference in a new issue