veza/apps/web/src/docs/STATE_SELECTORS.md

6.6 KiB

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:

// ❌ 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:

// ✅ GOOD: Only re-renders when `user` changes
function MyComponent() {
  const user = useAuthUser();
  return <div>{user?.username}</div>;
}

Available Selectors

Auth Store Selectors

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

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

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

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:

import { useStoreSelector } from '@/utils/storeSelectors';
import { useAuthStore } from '@/features/auth/store/authStore';

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:

// ❌ 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:

// ❌ 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:

// ❌ 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:

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)

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)

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:

import { render } from '@testing-library/react';
import { useAuthStore } from '@/features/auth/store/authStore';

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();
});