perf(ui): useDeferredValue on the Bills + Subscriptions search filters (Phase 2 stack leverage)

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 13:16:21 -05:00
parent 57e7ede670
commit a6bc85b18c
2 changed files with 11 additions and 6 deletions

View File

@ -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<number | null>(null);
const [dropTargetId, setDropTargetId] = useState<number | null>(null);
const [movingBillId, setMovingBillId] = useState<number | string | null>(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);

View File

@ -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(