/** * Server-paginated infinite list (no react-query). * * `loader(page)` fetches ONE page; pages are accumulated into `items`. The list * never loads everything up front — `loadMore()` (wired to a scroll sentinel) * advances exactly one page at a time until `hasMore` is false. * * Changing `resetKey` (e.g. the filter/sort/search signature) clears the list and * reloads from page 1. `reload()` re-fetches page 1 on demand (e.g. retry). * * Design mirrors useApi (and satisfies the strict react-hooks rules): * - the request signature lives in `req`; the query reset on `resetKey` change is * an in-render state adjustment (not a setState-in-effect), so no cascade. * - `loading` / `loadingMore` are DERIVED from resolved-vs-current request keys, * never set synchronously inside the fetch effect. * - the loader is read through a ref so the effect keys purely on the request id. */ import { useCallback, useEffect, useRef, useState } from 'react' import { messageFor } from './useApi' import type { Paged } from '../api/types' export interface InfiniteListState { items: T[] total: number /** Initial / reset load (page 1) in flight. */ loading: boolean /** A subsequent page is being appended. */ loadingMore: boolean error: string | null hasMore: boolean /** Advance one page; the scroll sentinel calls this. */ loadMore: () => void /** Re-fetch from page 1 (retry). */ reload: () => void } interface Req { key: string page: number nonce: number } interface Resolved { reqId: string items: T[] total: number error: string | null } export function useInfiniteList( loader: (page: number) => Promise>, resetKey: string, ): InfiniteListState { const loaderRef = useRef(loader) useEffect(() => { loaderRef.current = loader }) const [req, setReq] = useState({ key: resetKey, page: 1, nonce: 0 }) // Adjust state during render when the query signature changes — the recommended // pattern for deriving state from changed inputs (no setState-in-effect cascade). if (req.key !== resetKey) { setReq({ key: resetKey, page: 1, nonce: 0 }) } const queryId = `${req.nonce}:${req.key}` const reqId = `${queryId}#${req.page}` const [resolved, setResolved] = useState>({ reqId: '', items: [], total: 0, error: null, }) useEffect(() => { let cancelled = false const page = req.page const id = `${req.nonce}:${req.key}#${page}` loaderRef .current(page) .then((res) => { if (cancelled) return setResolved((prev) => ({ reqId: id, // page 1 replaces (fresh query); later pages append. items: page === 1 ? res.items : [...prev.items, ...res.items], total: res.totalCount, error: null, })) }) .catch((err) => { if (!cancelled) setResolved((prev) => ({ ...prev, reqId: id, error: messageFor(err) })) }) return () => { cancelled = true } }, [req]) // req is a fresh object per change (key/page/nonce) → one fetch per change // Resolved data only counts if it belongs to the current query signature. const resolvedQueryId = resolved.reqId.slice(0, resolved.reqId.lastIndexOf('#')) const sameQuery = resolved.reqId !== '' && resolvedQueryId === queryId const inFlight = resolved.reqId !== reqId const items = sameQuery ? resolved.items : [] const total = sameQuery ? resolved.total : 0 const loading = !sameQuery || (inFlight && req.page === 1) const loadingMore = sameQuery && inFlight && req.page > 1 const error = inFlight ? null : sameQuery ? resolved.error : null const hasMore = sameQuery && !inFlight && items.length < total const loadMore = useCallback(() => setReq((r) => ({ ...r, page: r.page + 1 })), []) const reload = useCallback(() => setReq((r) => ({ key: r.key, page: 1, nonce: r.nonce + 1 })), []) return { items, total, loading, loadingMore, error, hasMore, loadMore, reload } }