Problem
TransactionTable's search box re-filters the entire loaded transaction list on every keystroke, with no debounce and no memoization, despite the codebase already having a working useDebounce hook that's used correctly elsewhere.
// src/components/history/TransactionTable.tsx:13-30
export function TransactionTable() {
const { network } = useWallet()
const [search, setSearch] = useState('')
const { data, isLoading, isError, error, fetchNextPage, hasNextPage, isFetchingNextPage } =
useTransactions()
const allTransactions: Transaction[] =
data?.pages.flatMap((p) => p.transactions) ?? []
const filtered = search.trim()
? allTransactions.filter(
(tx) =>
tx.hash.toLowerCase().includes(search.toLowerCase()) ||
tx.counterparty.toLowerCase().includes(search.toLowerCase()) ||
tx.assetCode.toLowerCase().includes(search.toLowerCase()),
)
: allTransactions
...
Every one of data?.pages.flatMap(...) (rebuilding a new array from all loaded pages), search.toLowerCase() (recomputed 3× per transaction), and the full .filter() pass runs synchronously inside the component body on every render, and every render is triggered directly by setSearch(e.target.value) on the <input>'s onChange — i.e. on every single keystroke, with no useMemo to skip the work when search/allTransactions haven't meaningfully changed, and no debounce to coalesce rapid typing into fewer passes.
useDebounce already exists in the codebase and is used correctly by useContactSearch for exactly this kind of input-driven filtering:
// src/hooks/useContactSearch.ts
const debouncedQuery = useDebounce(query, 200)
return useMemo(() => { ... }, [contacts, debouncedQuery])
TransactionTable.tsx does neither of these things.
Why it matters
useTransactions() is an infinite/paginated query (src/hooks/useTransactions.ts:35-54) whose whole purpose is accumulating more and more transactions into data.pages as the user clicks "Load More" — this list is explicitly designed to grow unbounded within a session, unlike the fixed, small useRecentTransactions(5) list used elsewhere. A user who has clicked "Load More" a handful of times (very plausible for an active wallet, and the entire reason pagination exists in the first place) can easily have several hundred transactions loaded. At that point, every keystroke while typing a search term does a full flatMap over all loaded pages plus a .filter() with 3 substring checks per transaction, synchronously, on the main thread, blocking input responsiveness — visibly janky typing for exactly the users this feature matters most for (people with enough transaction history that they actually need to search rather than just scroll).
This is a real, demonstrable performance regression path, not a hypothetical one: it's the direct, mechanical consequence of combining an unbounded, ever-growing list with an unmemoized, undebounced per-keystroke filter — and the fix pattern (useDebounce + useMemo) already exists in the same codebase, just not applied here.
Reproduction
- Mock/fixture
useTransactions() to return several hundred transactions across multiple loaded pages (simulating a handful of "Load More" clicks). Type quickly into the search box and observe: each keystroke triggers a fresh flatMap + filter over the full list synchronously before the next render, with no debouncing — measurable via a render-count/timing assertion, or simply profiling keystroke-to-paint latency against the equivalent debounced/memoized useContactSearch pattern.
Suggested fix
- Wrap the filtering in
useMemo(() => ..., [allTransactions, debouncedSearch]), and debounce search via the existing useDebounce(search, 200) hook before it's used to filter, exactly mirroring useContactSearch's already-correct pattern in this same codebase.
- Memoize
allTransactions itself (useMemo(() => data?.pages.flatMap(p => p.transactions) ?? [], [data])) so the flatMap isn't rebuilt on renders unrelated to data changing (e.g. purely from search changing).
Additional Notes
src/components/history/TransactionTable.tsx:13-30 (the unmemoized, undebounced filter), src/hooks/useDebounce.ts (the existing, unused-here utility), src/hooks/useContactSearch.ts (the correct pattern already present in the same codebase).
- Testing strategy: a test that mounts
TransactionTable with a large fixture list, simulates rapid sequential keystrokes via userEvent.type, and asserts the expensive filter computation runs a bounded number of times (e.g. via a spy/counter on the filter predicate) rather than once per keystroke.
Problem
TransactionTable's search box re-filters the entire loaded transaction list on every keystroke, with no debounce and no memoization, despite the codebase already having a workinguseDebouncehook that's used correctly elsewhere.Every one of
data?.pages.flatMap(...)(rebuilding a new array from all loaded pages),search.toLowerCase()(recomputed 3× per transaction), and the full.filter()pass runs synchronously inside the component body on every render, and every render is triggered directly bysetSearch(e.target.value)on the<input>'sonChange— i.e. on every single keystroke, with nouseMemoto skip the work whensearch/allTransactionshaven't meaningfully changed, and no debounce to coalesce rapid typing into fewer passes.useDebouncealready exists in the codebase and is used correctly byuseContactSearchfor exactly this kind of input-driven filtering:TransactionTable.tsxdoes neither of these things.Why it matters
useTransactions()is an infinite/paginated query (src/hooks/useTransactions.ts:35-54) whose whole purpose is accumulating more and more transactions intodata.pagesas the user clicks "Load More" — this list is explicitly designed to grow unbounded within a session, unlike the fixed, smalluseRecentTransactions(5)list used elsewhere. A user who has clicked "Load More" a handful of times (very plausible for an active wallet, and the entire reason pagination exists in the first place) can easily have several hundred transactions loaded. At that point, every keystroke while typing a search term does a fullflatMapover all loaded pages plus a.filter()with 3 substring checks per transaction, synchronously, on the main thread, blocking input responsiveness — visibly janky typing for exactly the users this feature matters most for (people with enough transaction history that they actually need to search rather than just scroll).This is a real, demonstrable performance regression path, not a hypothetical one: it's the direct, mechanical consequence of combining an unbounded, ever-growing list with an unmemoized, undebounced per-keystroke filter — and the fix pattern (
useDebounce+useMemo) already exists in the same codebase, just not applied here.Reproduction
useTransactions()to return several hundred transactions across multiple loaded pages (simulating a handful of "Load More" clicks). Type quickly into the search box and observe: each keystroke triggers a freshflatMap+filterover the full list synchronously before the next render, with no debouncing — measurable via a render-count/timing assertion, or simply profiling keystroke-to-paint latency against the equivalent debounced/memoizeduseContactSearchpattern.Suggested fix
useMemo(() => ..., [allTransactions, debouncedSearch]), and debouncesearchvia the existinguseDebounce(search, 200)hook before it's used to filter, exactly mirroringuseContactSearch's already-correct pattern in this same codebase.allTransactionsitself (useMemo(() => data?.pages.flatMap(p => p.transactions) ?? [], [data])) so theflatMapisn't rebuilt on renders unrelated todatachanging (e.g. purely fromsearchchanging).Additional Notes
src/components/history/TransactionTable.tsx:13-30(the unmemoized, undebounced filter),src/hooks/useDebounce.ts(the existing, unused-here utility),src/hooks/useContactSearch.ts(the correct pattern already present in the same codebase).TransactionTablewith a large fixture list, simulates rapid sequential keystrokes viauserEvent.type, and asserts the expensive filter computation runs a bounded number of times (e.g. via a spy/counter on the filter predicate) rather than once per keystroke.