Skip to content

TransactionTable's search filters the full loaded transaction list on every keystroke with no debounce or memoization #50

Description

@abayomicornelius

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignbugSomething isn't workingenhancementNew feature or requestvery hardVery difficult / senior-level bounty issue

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions