From a6bc85b18ce4618a20d2f03e24ed31c1a953896a Mon Sep 17 00:00:00 2001 From: null Date: Sun, 5 Jul 2026 13:16:21 -0500 Subject: [PATCH] perf(ui): useDeferredValue on the Bills + Subscriptions search filters (Phase 2 stack leverage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both pages filter the whole list on every keystroke. The input now updates instantly while the (deferred) filter+render lags behind — React 19's useDeferredValue, which the app wasn't using anywhere. Typing stays snappy on large lists; zero behavior change otherwise. typecheck/lint/build clean, probe 17/17. Co-Authored-By: Claude Opus 4.8 --- client/pages/BillsPage.tsx | 9 ++++++--- client/pages/SubscriptionsPage.tsx | 8 +++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/client/pages/BillsPage.tsx b/client/pages/BillsPage.tsx index 67d6165..7f0bb31 100644 --- a/client/pages/BillsPage.tsx +++ b/client/pages/BillsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; +import { useEffect, useState, useCallback, useMemo, useRef, useDeferredValue } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { useBills, useCategories, useBillTemplates, useDeletedBills } from '@/hooks/useQueries'; @@ -676,6 +676,9 @@ export default function BillsPage() { const [showDeleted, setShowDeleted] = useState(false); const [showInactive, setShowInactive] = useState(false); const [search, setSearch] = useState(''); + // Keep typing responsive: the input updates `search` instantly while the + // (whole-list) filter below runs against the deferred value (React 19). + const deferredSearch = useDeferredValue(search); const [draggingId, setDraggingId] = useState(null); const [dropTargetId, setDropTargetId] = useState(null); const [movingBillId, setMovingBillId] = useState(null); @@ -860,7 +863,7 @@ export default function BillsPage() { ), [bills]); const filteredBills = useMemo(() => { - const q = search.trim().toLowerCase(); + const q = deferredSearch.trim().toLowerCase(); return bills.filter(bill => { if (filters.inactive && bill.active) return false; if (filters.category !== FILTER_ALL && String(bill.category_id ?? '') !== filters.category) return false; @@ -882,7 +885,7 @@ export default function BillsPage() { ].filter(Boolean).join(' ').toLowerCase(); return haystack.includes(q); }); - }, [bills, filters, search]); + }, [bills, filters, deferredSearch]); const active = filteredBills.filter(b => b.active); const inactive = filteredBills.filter(b => !b.active); diff --git a/client/pages/SubscriptionsPage.tsx b/client/pages/SubscriptionsPage.tsx index 209d9cf..41e9647 100644 --- a/client/pages/SubscriptionsPage.tsx +++ b/client/pages/SubscriptionsPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState, type ComponentType, type ComponentProps, type ReactNode } from 'react'; +import { useCallback, useDeferredValue, useEffect, useMemo, useOptimistic, useRef, useState, type ComponentType, type ComponentProps, type ReactNode } from 'react'; import { Link } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { useSubscriptions, useSubscriptionRecommendations, useCategories, useBills } from '@/hooks/useQueries'; @@ -1348,8 +1348,10 @@ export default function SubscriptionsPage() { useCallback((state: Subscription[], { id, active }: { id: number; active: boolean }) => state.map(s => s.id === id ? { ...s, active } : s), []), ); + // Deferred so typing in the search box stays snappy while the list re-filters. + const deferredSubSearch = useDeferredValue(subSearch); const filteredSubscriptions = useMemo(() => { - const q = subSearch.trim().toLowerCase(); + const q = deferredSubSearch.trim().toLowerCase(); const base = q ? optimisticSubs.filter(item => String(item.name || '').toLowerCase().includes(q) || @@ -1358,7 +1360,7 @@ export default function SubscriptionsPage() { ) : optimisticSubs; return base; - }, [optimisticSubs, subSearch]); + }, [optimisticSubs, deferredSubSearch]); const active = filteredSubscriptions.filter(item => item.active); const paused = filteredSubscriptions.filter(item => !item.active); const sortedActive = useMemo(