refactor(bank): migrate BankTransactionsPage ledger to React Query (R5.7)

The paginated/filtered ledger (the race-prone data) moves to a useBankLedger
query keyed on account/flow/page/query/sort — React Query handles caching,
dedup, cancellation and out-of-order responses, replacing the manual request-id
guard. Optimistic categorize routes through a setLedger setQueryData wrapper;
loadLedger is the query's refetch (mutations + Refresh); the refresh button uses
isFetching. Mount-once categories/bills stay local loads. This completes R5 —
all 7 manual-fetch pages are on React Query.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 20:25:47 -05:00
parent 2e8bc07552
commit 6802a66e82
2 changed files with 31 additions and 33 deletions

View File

@ -110,6 +110,23 @@ export function useSummary(year, month) {
});
}
export function useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize }) {
return useQuery({
queryKey: ['bank-ledger', accountId, flow, page, query, sortBy, sortDir],
queryFn: () => api.bankTransactionsLedger({
limit: pageSize,
offset: (page - 1) * pageSize,
q: query,
account_id: accountId === 'all' ? undefined : accountId,
flow,
sort_by: sortBy,
sort_dir: sortDir,
}),
staleTime: 1000 * 60,
placeholderData: keepPreviousData,
});
}
export function useSpendingSummary(year, month) {
return useQuery({
queryKey: ['spending-summary', year, month],

View File

@ -1,5 +1,7 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useBankLedger } from '@/hooks/useQueries';
import { toast } from 'sonner';
import {
ArrowDown,
@ -307,9 +309,7 @@ function TransactionMobileCard({ tx, categories, onCategorize, onApplySuggestion
}
export default function BankTransactionsPage() {
const [ledger, setLedger] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const queryClient = useQueryClient();
const [search, setSearch] = useState('');
const [query, setQuery] = useState('');
const [accountId, setAccountId] = useState('all');
@ -327,9 +327,14 @@ export default function BankTransactionsPage() {
const [createBillSourceTx, setCreateBillSourceTx] = useState(null);
const [selectedIds, setSelectedIds] = useState(() => new Set());
const [bulkActing, setBulkActing] = useState(false);
// Monotonic request id: a response only lands if it belongs to the newest
// request, so a slow Refresh can never overwrite fresher filter results.
const requestSeq = useRef(0);
// React Query keys the ledger on the filters/page, so it handles caching,
// dedup, cancellation, and out-of-order responses (no manual request id).
const { data: ledger = null, isPending: loading, isFetching, error: ledgerErr, refetch: loadLedger } =
useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize: PAGE_SIZE });
const error = ledgerErr ? (ledgerErr.message || 'Unable to load bank transactions') : '';
const setLedger = useCallback((u) => queryClient.setQueryData(
['bank-ledger', accountId, flow, page, query, sortBy, sortDir],
prev => (typeof u === 'function' ? u(prev) : u)), [queryClient, accountId, flow, page, query, sortBy, sortDir]);
useEffect(() => {
const id = window.setTimeout(() => {
@ -352,30 +357,6 @@ export default function BankTransactionsPage() {
api.bills().then(data => setBills(data || [])).catch(() => setBills([]));
}, []);
// Single fetch path used by both the load effect and the Refresh button.
const loadLedger = useCallback(async () => {
const seq = ++requestSeq.current;
setLoading(true);
setError('');
try {
const data = await api.bankTransactionsLedger({
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
q: query,
account_id: accountId === 'all' ? undefined : accountId,
flow,
sort_by: sortBy,
sort_dir: sortDir,
});
if (seq === requestSeq.current) setLedger(data);
} catch (err) {
if (seq === requestSeq.current) setError(err.message || 'Unable to load bank transactions');
} finally {
if (seq === requestSeq.current) setLoading(false);
}
}, [accountId, flow, page, query, sortBy, sortDir]);
useEffect(() => { loadLedger(); }, [loadLedger]);
const updateTransaction = useCallback((id, patch) => {
setLedger(prev => {
@ -385,7 +366,7 @@ export default function BankTransactionsPage() {
transactions: prev.transactions.map(tx => (tx.id === id ? { ...tx, ...patch } : tx)),
};
});
}, []);
}, [setLedger]);
const handleCategorize = useCallback(async (tx, categoryId) => {
try {
@ -739,7 +720,7 @@ export default function BankTransactionsPage() {
<Sparkles className={cn('h-4 w-4', autoCategorizing && 'animate-pulse')} />
Auto-categorize
</Button>
<Button type="button" variant="outline" onClick={loadLedger} disabled={loading}>
<Button type="button" variant="outline" onClick={loadLedger} disabled={isFetching}>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
Refresh
</Button>