proposal-system/web/src/hooks/usePaginatedList.ts
Adam Moussa 184bc1da7e Fix Phase 2 audit findings: reliability, UX, and operational monitoring
BLOCK-10: Add CloudWatch alarms (DLQ, Lambda errors, RDS, API 5xx) with SNS email
BLOCK-11: Remove sync-over-async deadlock in CurrentUserService
BLOCK-12: Add AppDelegate OAuth URL callback handler for mobile
BLOCK-13: Wire mobile 401 interceptor to dispatch Redux logout
BLOCK-14: Fix JWT base64 padding crash and SysAdmin role detection
BLOCK-15: Reset pagination to page 1 on filter change
BLOCK-16: Add unsaved-changes guard (beforeunload + useBlocker) to AdminWorkspace
FIX-08: Add BulkUpdateLineItems FluentValidation validator
FIX-13: Display auth errors on LoginPage
FIX-25: Add token refresh with retry queue to mobile API client
FIX-44: Add httpx retry logic to all Lambda handlers
FIX-42/43: Align docker-compose PG version (15) and DB name (proposals) with RDS
2026-05-20 19:07:49 -04:00

76 lines
2.2 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../constants';
interface PaginatedResult<T> {
items: T[];
totalCount: number;
}
type FetchFn<T> = (params: Record<string, unknown>) => Promise<PaginatedResult<T>>;
export function usePaginatedList<T>(fetchFn: FetchFn<T>, extraParams: Record<string, unknown> = {}) {
const [rows, setRows] = useState<T[]>([]);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [page, setPage] = useState(DEFAULT_PAGE);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState('');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const extraKey = JSON.stringify(extraParams);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setDebouncedSearch(search);
setPage(1);
}, DEBOUNCE_SEARCH);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [search]);
const prevExtraKey = useRef(extraKey);
useEffect(() => {
if (prevExtraKey.current !== extraKey) {
prevExtraKey.current = extraKey;
setPage(1);
}
}, [extraKey]);
const reload = useCallback(() => {
setLoading(true);
setErr('');
fetchFn({ search: debouncedSearch, page, pageSize, ...extraParams })
.then(({ items, totalCount }) => {
setRows(items);
setTotalCount(totalCount);
})
.catch((e: Error) => setErr(e.message || 'Failed to load'))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchFn, debouncedSearch, page, pageSize, extraKey]);
useEffect(() => {
reload();
}, [reload]);
return useMemo(
() => ({
rows,
search,
setSearch,
page,
setPage,
pageSize,
setPageSize,
totalCount,
loading,
err,
reload,
}),
[rows, search, page, pageSize, totalCount, loading, err, reload]
);
}