From 03ad255bf1cf8d3d80552351e26c4b1dc11fb9b9 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Feb 2026 17:15:30 +0300 Subject: [PATCH] perf: add Zustand selectors to prevent cascading re-renders Replace all bare useAuthStore(), useBlockingStore(), and useSuccessNotification() calls with individual field selectors. This prevents components from re-rendering when unrelated store fields change. - 21 useAuthStore usages across 20 files: individual selectors for 1-2 fields, useShallow for 3+ fields - 5 useBlockingStore usages: individual selectors - 2 useSuccessNotification usages: individual selectors --- src/App.tsx | 9 ++++++--- src/components/SuccessNotificationModal.tsx | 4 +++- src/components/TicketNotificationBell.tsx | 2 +- src/components/WebSocketNotifications.tsx | 4 ++-- src/components/blocking/BlacklistedScreen.tsx | 2 +- .../blocking/ChannelSubscriptionScreen.tsx | 3 ++- src/components/blocking/MaintenanceScreen.tsx | 2 +- src/components/layout/AppShell/AppHeader.tsx | 5 ++++- src/components/layout/AppShell/AppShell.tsx | 3 ++- src/components/layout/AppShell/DesktopSidebar.tsx | 3 ++- .../navigation/CommandPalette/CommandPalette.tsx | 2 +- src/hooks/useFeatureFlags.ts | 2 +- src/pages/Balance.tsx | 2 +- src/pages/Connection.tsx | 3 ++- src/pages/Dashboard.tsx | 5 +++-- src/pages/Login.tsx | 11 ++++++++++- src/pages/OAuthCallback.tsx | 3 ++- src/pages/Profile.tsx | 3 ++- src/pages/TelegramCallback.tsx | 3 ++- src/pages/TelegramRedirect.tsx | 13 ++++++++++++- src/pages/VerifyEmail.tsx | 9 ++++++++- src/providers/WebSocketProvider.tsx | 3 ++- 22 files changed, 70 insertions(+), 26 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b39bd3e..7aa7fb7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -90,7 +90,8 @@ const AdminPinnedMessageCreate = lazy(() => import('./pages/AdminPinnedMessageCr const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview')); function ProtectedRoute({ children }: { children: React.ReactNode }) { - const { isAuthenticated, isLoading } = useAuthStore(); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const isLoading = useAuthStore((state) => state.isLoading); const location = useLocation(); if (isLoading) { @@ -107,7 +108,9 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { } function AdminRoute({ children }: { children: React.ReactNode }) { - const { isAuthenticated, isLoading, isAdmin } = useAuthStore(); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const isLoading = useAuthStore((state) => state.isLoading); + const isAdmin = useAuthStore((state) => state.isAdmin); const location = useLocation(); if (isLoading) { @@ -133,7 +136,7 @@ function LazyPage({ children }: { children: React.ReactNode }) { } function BlockingOverlay() { - const { blockingType } = useBlockingStore(); + const blockingType = useBlockingStore((state) => state.blockingType); if (blockingType === 'maintenance') { return ; diff --git a/src/components/SuccessNotificationModal.tsx b/src/components/SuccessNotificationModal.tsx index a97b49a..de87f6c 100644 --- a/src/components/SuccessNotificationModal.tsx +++ b/src/components/SuccessNotificationModal.tsx @@ -78,7 +78,9 @@ const CloseIcon = () => ( export default function SuccessNotificationModal() { const { t } = useTranslation(); const navigate = useNavigate(); - const { isOpen, data, hide } = useSuccessNotification(); + const isOpen = useSuccessNotification((state) => state.isOpen); + const data = useSuccessNotification((state) => state.data); + const hide = useSuccessNotification((state) => state.hide); const { formatAmount, currencySymbol } = useCurrency(); const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramSDK(); const haptic = useHaptic(); diff --git a/src/components/TicketNotificationBell.tsx b/src/components/TicketNotificationBell.tsx index 693f58d..24018a8 100644 --- a/src/components/TicketNotificationBell.tsx +++ b/src/components/TicketNotificationBell.tsx @@ -33,7 +33,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi const { t } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); - const { isAuthenticated } = useAuthStore(); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const { showToast } = useToast(); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); diff --git a/src/components/WebSocketNotifications.tsx b/src/components/WebSocketNotifications.tsx index 3baffb3..5d93cbe 100644 --- a/src/components/WebSocketNotifications.tsx +++ b/src/components/WebSocketNotifications.tsx @@ -18,9 +18,9 @@ export default function WebSocketNotifications() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { showToast } = useToast(); - const { refreshUser } = useAuthStore(); + const refreshUser = useAuthStore((state) => state.refreshUser); const { formatAmount, currencySymbol } = useCurrency(); - const { show: showSuccessModal } = useSuccessNotification(); + const showSuccessModal = useSuccessNotification((state) => state.show); const handleMessage = useCallback( (message: WSMessage) => { diff --git a/src/components/blocking/BlacklistedScreen.tsx b/src/components/blocking/BlacklistedScreen.tsx index 1cbedb8..c5051ad 100644 --- a/src/components/blocking/BlacklistedScreen.tsx +++ b/src/components/blocking/BlacklistedScreen.tsx @@ -3,7 +3,7 @@ import { useBlockingStore } from '../../store/blocking'; export default function BlacklistedScreen() { const { t } = useTranslation(); - const { blacklistedInfo } = useBlockingStore(); + const blacklistedInfo = useBlockingStore((state) => state.blacklistedInfo); return (
diff --git a/src/components/blocking/ChannelSubscriptionScreen.tsx b/src/components/blocking/ChannelSubscriptionScreen.tsx index d7cb93f..67bb643 100644 --- a/src/components/blocking/ChannelSubscriptionScreen.tsx +++ b/src/components/blocking/ChannelSubscriptionScreen.tsx @@ -7,7 +7,8 @@ const CHECK_COOLDOWN_SECONDS = 5; export default function ChannelSubscriptionScreen() { const { t } = useTranslation(); - const { channelInfo, clearBlocking } = useBlockingStore(); + const channelInfo = useBlockingStore((state) => state.channelInfo); + const clearBlocking = useBlockingStore((state) => state.clearBlocking); const [isChecking, setIsChecking] = useState(false); const [cooldown, setCooldown] = useState(0); const [error, setError] = useState(null); diff --git a/src/components/blocking/MaintenanceScreen.tsx b/src/components/blocking/MaintenanceScreen.tsx index f51e68c..6302eed 100644 --- a/src/components/blocking/MaintenanceScreen.tsx +++ b/src/components/blocking/MaintenanceScreen.tsx @@ -3,7 +3,7 @@ import { useBlockingStore } from '../../store/blocking'; export default function MaintenanceScreen() { const { t } = useTranslation(); - const { maintenanceInfo } = useBlockingStore(); + const maintenanceInfo = useBlockingStore((state) => state.maintenanceInfo); return (
diff --git a/src/components/layout/AppShell/AppHeader.tsx b/src/components/layout/AppShell/AppHeader.tsx index 1ff846d..f47fe3c 100644 --- a/src/components/layout/AppShell/AppHeader.tsx +++ b/src/components/layout/AppShell/AppHeader.tsx @@ -5,6 +5,7 @@ import { useState, useEffect } from 'react'; import { initDataUser } from '@telegram-apps/sdk-react'; import { useAuthStore } from '@/store/auth'; +import { useShallow } from 'zustand/shallow'; import { useTheme } from '@/hooks/useTheme'; import { usePlatform } from '@/platform'; import { @@ -77,7 +78,9 @@ export function AppHeader({ }: AppHeaderProps) { const { t } = useTranslation(); const location = useLocation(); - const { user, logout, isAdmin } = useAuthStore(); + const { user, logout, isAdmin } = useAuthStore( + useShallow((state) => ({ user: state.user, logout: state.logout, isAdmin: state.isAdmin })), + ); const { toggleTheme, isDark } = useTheme(); const { haptic, platform } = usePlatform(); const [userPhotoUrl, setUserPhotoUrl] = useState(null); diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx index eb339f4..0b2de7b 100644 --- a/src/components/layout/AppShell/AppShell.tsx +++ b/src/components/layout/AppShell/AppShell.tsx @@ -193,7 +193,8 @@ interface AppShellProps { export function AppShell({ children }: AppShellProps) { const { t } = useTranslation(); const location = useLocation(); - const { isAdmin, logout } = useAuthStore(); + const isAdmin = useAuthStore((state) => state.isAdmin); + const logout = useAuthStore((state) => state.logout); const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } = useTelegramSDK(); const haptic = useHaptic(); diff --git a/src/components/layout/AppShell/DesktopSidebar.tsx b/src/components/layout/AppShell/DesktopSidebar.tsx index 8f36328..4af5611 100644 --- a/src/components/layout/AppShell/DesktopSidebar.tsx +++ b/src/components/layout/AppShell/DesktopSidebar.tsx @@ -49,7 +49,8 @@ export function DesktopSidebar({ }: DesktopSidebarProps) { const { t } = useTranslation(); const location = useLocation(); - const { user, logout } = useAuthStore(); + const user = useAuthStore((state) => state.user); + const logout = useAuthStore((state) => state.logout); const { haptic } = usePlatform(); // Branding diff --git a/src/components/navigation/CommandPalette/CommandPalette.tsx b/src/components/navigation/CommandPalette/CommandPalette.tsx index c949ec7..e5add68 100644 --- a/src/components/navigation/CommandPalette/CommandPalette.tsx +++ b/src/components/navigation/CommandPalette/CommandPalette.tsx @@ -62,7 +62,7 @@ export function CommandPalette({ const { t } = useTranslation(); const navigate = useNavigate(); const { haptic } = usePlatform(); - const { isAdmin } = useAuthStore(); + const isAdmin = useAuthStore((state) => state.isAdmin); const { toggleTheme, isDark } = useTheme(); const [search, setSearch] = useState(''); diff --git a/src/hooks/useFeatureFlags.ts b/src/hooks/useFeatureFlags.ts index 594e4a9..356d357 100644 --- a/src/hooks/useFeatureFlags.ts +++ b/src/hooks/useFeatureFlags.ts @@ -6,7 +6,7 @@ import { contestsApi } from '@/api/contests'; import { pollsApi } from '@/api/polls'; export function useFeatureFlags() { - const { isAuthenticated } = useAuthStore(); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const { data: referralTerms } = useQuery({ queryKey: ['referral-terms'], diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx index 0fe2a71..f6f9809 100644 --- a/src/pages/Balance.tsx +++ b/src/pages/Balance.tsx @@ -40,7 +40,7 @@ const WalletIcon = ({ className = 'h-8 w-8' }: { className?: string }) => ( export default function Balance() { const { t } = useTranslation(); - const { refreshUser } = useAuthStore(); + const refreshUser = useAuthStore((state) => state.refreshUser); const queryClient = useQueryClient(); const { formatAmount, currencySymbol } = useCurrency(); const [searchParams] = useSearchParams(); diff --git a/src/pages/Connection.tsx b/src/pages/Connection.tsx index e9fe6c9..2b250cb 100644 --- a/src/pages/Connection.tsx +++ b/src/pages/Connection.tsx @@ -14,7 +14,8 @@ import InstallationGuide from '../components/connection/InstallationGuide'; export default function Connection() { const { t, i18n } = useTranslation(); const navigate = useNavigate(); - const { user, isAdmin } = useAuthStore(); + const user = useAuthStore((state) => state.user); + const isAdmin = useAuthStore((state) => state.isAdmin); const { isTelegramWebApp } = useTelegramSDK(); const { impact: hapticImpact } = useHaptic(); diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index ee246f2..cbccda3 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -48,14 +48,15 @@ const RefreshIcon = ({ className = 'w-4 h-4' }: { className?: string }) => ( export default function Dashboard() { const { t } = useTranslation(); - const { user, refreshUser } = useAuthStore(); + const user = useAuthStore((state) => state.user); + const refreshUser = useAuthStore((state) => state.refreshUser); const queryClient = useQueryClient(); const navigate = useNavigate(); const { formatAmount, currencySymbol, formatPositive } = useCurrency(); const [trialError, setTrialError] = useState(null); const { isCompleted: isOnboardingCompleted, complete: completeOnboarding } = useOnboarding(); const [showOnboarding, setShowOnboarding] = useState(false); - const { blockingType } = useBlockingStore(); + const blockingType = useBlockingStore((state) => state.blockingType); // Refresh user data on mount useEffect(() => { diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index d429cdb..0203b7e 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -3,6 +3,7 @@ import { useNavigate, useLocation } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { useAuthStore } from '../store/auth'; +import { useShallow } from 'zustand/shallow'; import { authApi } from '../api/auth'; import { isValidEmail } from '../utils/validation'; import { @@ -32,7 +33,15 @@ export default function Login() { loginWithTelegram, loginWithEmail, registerWithEmail, - } = useAuthStore(); + } = useAuthStore( + useShallow((state) => ({ + isAuthenticated: state.isAuthenticated, + isLoading: state.isLoading, + loginWithTelegram: state.loginWithTelegram, + loginWithEmail: state.loginWithEmail, + registerWithEmail: state.registerWithEmail, + })), + ); // Get referral code from localStorage (captured from ?ref= param at module level in auth store) const referralCode = getPendingReferralCode() || ''; diff --git a/src/pages/OAuthCallback.tsx b/src/pages/OAuthCallback.tsx index 7078fc5..dfe797f 100644 --- a/src/pages/OAuthCallback.tsx +++ b/src/pages/OAuthCallback.tsx @@ -26,7 +26,8 @@ export default function OAuthCallback() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [error, setError] = useState(''); - const { loginWithOAuth, isAuthenticated } = useAuthStore(); + const loginWithOAuth = useAuthStore((state) => state.loginWithOAuth); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); useEffect(() => { if (isAuthenticated) { diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 1437b26..8884393 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -61,7 +61,8 @@ const PencilIcon = () => ( export default function Profile() { const { t } = useTranslation(); - const { user, setUser } = useAuthStore(); + const user = useAuthStore((state) => state.user); + const setUser = useAuthStore((state) => state.setUser); const queryClient = useQueryClient(); const [email, setEmail] = useState(''); diff --git a/src/pages/TelegramCallback.tsx b/src/pages/TelegramCallback.tsx index 3908d54..7df21d8 100644 --- a/src/pages/TelegramCallback.tsx +++ b/src/pages/TelegramCallback.tsx @@ -8,7 +8,8 @@ export default function TelegramCallback() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [error, setError] = useState(''); - const { loginWithTelegramWidget, isAuthenticated } = useAuthStore(); + const loginWithTelegramWidget = useAuthStore((state) => state.loginWithTelegramWidget); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); useEffect(() => { if (isAuthenticated) { diff --git a/src/pages/TelegramRedirect.tsx b/src/pages/TelegramRedirect.tsx index 963b9eb..107a8ef 100644 --- a/src/pages/TelegramRedirect.tsx +++ b/src/pages/TelegramRedirect.tsx @@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { useAuthStore } from '../store/auth'; +import { useShallow } from 'zustand/shallow'; import { brandingApi } from '../api/branding'; import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK'; @@ -33,7 +34,17 @@ export default function TelegramRedirect() { const { t } = useTranslation(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const { loginWithTelegram, isAuthenticated, isLoading: authLoading } = useAuthStore(); + const { + loginWithTelegram, + isAuthenticated, + isLoading: authLoading, + } = useAuthStore( + useShallow((state) => ({ + loginWithTelegram: state.loginWithTelegram, + isAuthenticated: state.isAuthenticated, + isLoading: state.isLoading, + })), + ); const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'not-telegram'>('loading'); const [errorMessage, setErrorMessage] = useState(''); const [retryCount, setRetryCount] = useState(() => { diff --git a/src/pages/VerifyEmail.tsx b/src/pages/VerifyEmail.tsx index 9f35539..e9f0a6c 100644 --- a/src/pages/VerifyEmail.tsx +++ b/src/pages/VerifyEmail.tsx @@ -3,6 +3,7 @@ import { useSearchParams, Link, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { authApi } from '../api/auth'; import { useAuthStore } from '../store/auth'; +import { useShallow } from 'zustand/shallow'; import { consumeCampaignSlug } from '../utils/campaign'; import { tokenStorage } from '../utils/token'; import LanguageSwitcher from '../components/LanguageSwitcher'; @@ -13,7 +14,13 @@ export default function VerifyEmail() { const [searchParams] = useSearchParams(); const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); const [error, setError] = useState(''); - const { setTokens, setUser, checkAdminStatus } = useAuthStore(); + const { setTokens, setUser, checkAdminStatus } = useAuthStore( + useShallow((state) => ({ + setTokens: state.setTokens, + setUser: state.setUser, + checkAdminStatus: state.checkAdminStatus, + })), + ); const hasVerified = useRef(false); useEffect(() => { diff --git a/src/providers/WebSocketProvider.tsx b/src/providers/WebSocketProvider.tsx index e4d3445..dd95624 100644 --- a/src/providers/WebSocketProvider.tsx +++ b/src/providers/WebSocketProvider.tsx @@ -9,7 +9,8 @@ export type { WSMessage } from './WebSocketContext'; const isDev = import.meta.env.DEV; export function WebSocketProvider({ children }: { children: React.ReactNode }) { - const { accessToken, isAuthenticated } = useAuthStore(); + const accessToken = useAuthStore((state) => state.accessToken); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const wsRef = useRef(null); const reconnectTimeoutRef = useRef | null>(null); const pingIntervalRef = useRef | null>(null);