Skip to content

feat(hooks): add usePaginatedQuery built on useOptimisticData#367

Open
JuliobaCR wants to merge 1 commit into
Grainlify:mainfrom
JuliobaCR:feat/use-paginated-query
Open

feat(hooks): add usePaginatedQuery built on useOptimisticData#367
JuliobaCR wants to merge 1 commit into
Grainlify:mainfrom
JuliobaCR:feat/use-paginated-query

Conversation

@JuliobaCR

@JuliobaCR JuliobaCR commented Jun 27, 2026

Copy link
Copy Markdown

Summary

Closes #304.

  • Adds usePaginatedQuery<T> (src/shared/hooks/usePaginatedQuery.ts), a typed pagination layer built on useOptimisticData and the shared helpers in src/shared/utils/pagination.ts. It centralizes offset tracking, append-vs-replace, and end-of-list detection (via hasMoreByTotal / hasMoreByPageSize) so list pages stop reimplementing this logic — and its off-by-one/end-of-list bugs — individually.
  • Migrates BrowsePage as the reference consumer: its bespoke loadProjects/offset-ref/loadingMore-ref/request-seq-ref block is replaced by a single fetchProjectsPage callback + one usePaginatedQuery call. Net diff on BrowsePage.tsx is -76 lines.
  • limit/offset are clamped to bounded, non-negative integers (via clampLimit/clampOffset) before ever reaching fetchPage, so a caller-influenced page size or a runaway offset can't reach the API unbounded.
  • Request cancellation, retry/backoff, and loading/error state are all inherited directly from useOptimisticData. Every page request bypasses useOptimisticData's response cache (forceRefresh), since each call targets a different offset — a single-key cache would otherwise risk replaying a stale page instead of fetching the next one.
  • The hook's PaginatedPage<T> contract has an explicit received field, distinct from items.length, so a fetchPage that filters/maps rows (like BrowsePage dropping invalid repos) still advances the offset by the API's raw page size — not the smaller displayed count. This preserves a subtlety the original BrowsePage code handled carefully and that would otherwise reintroduce exactly the kind of off-by-one bug this issue is about preventing.

API

const fetchPage = useCallback<FetchPageFn<Project>>(
  async ({ limit, offset }) => {
    const response = await getPublicProjects({ ...filterParams, limit, offset })
    return { items: mapApiProjects(response.projects), total: response.total, received: response.projects.length }
  },
  [filterParams]
)

const { items, total, hasMore, isLoading, isLoadingMore, hasError, error, loadMore, reset, retry } =
  usePaginatedQuery(fetchPage, { limit: PAGE_SIZE })

The first page loads automatically on mount and again whenever fetchPage's identity changes (e.g. a useCallback keyed on filters) — mirroring how BrowsePage previously reloaded on selectedFilters changes, with no extra effect needed in the consumer.

Test plan

New src/shared/hooks/usePaginatedQuery.test.ts (17 tests), covering:

  • Empty list response sets hasMore to false immediately
  • Single page (total known, fits in one page) does not show load-more
  • Exact-multiple page size boundary: hasMore flips truefalse on the page that exactly exhausts total
  • Bare-array endpoints without total via the page-size heuristic
  • loadMore appends rather than replaces; is a no-op once hasMore is false; guards against concurrent double-clicks
  • reset() discards accumulated items and refetches from offset 0
  • Pagination auto-restarts at offset 0 when fetchPage's identity changes (filter change)
  • Offset advances by the raw received count, not the (possibly smaller) filtered items count
  • Non-array items handled defensively
  • A failed load-more surfaces via hasError/error without clearing previously-loaded items or hasMore
  • retry() re-attempts the exact failed page (same offset) and recovers
  • Request cancellation: aborts in-flight request on unmount
  • Race condition: a stale load-more response that resolves after a newer reset already landed is ignored

Ran via npx vitest run usePaginatedQuery BrowsePage: 41/41 passing (17 new + 24 existing BrowsePage tests, unmodified, still green).

Coverage for the new hook (npx vitest run usePaginatedQuery --coverage): 100% lines/statements/functions/branches. BrowsePage.tsx's existing coverage is unchanged-to-slightly-improved post-migration (branches 89.16% → 91.3%, statements 97.5% → 99.11%).

npx tsc --noEmit and npx eslint src show no new errors introduced by this change (both have a handful of pre-existing, unrelated warnings/errors on main — e.g. RefObject typing in SearchModal.tsx/Modal.tsx/focusTrap.test.tsx — left untouched as out of scope).

Security notes

  • limit is clamped to [1, MAX_PAGE_LIMIT] and offset to [0, MAX_OFFSET] via the existing pagination.ts helpers inside the hook itself, before any value reaches fetchPage/the API — a consumer cannot accidentally (or via malicious filter input) request an oversized page or an unbounded deep-paging offset.
  • No new dependencies, no new network surface; the hook only orchestrates calls a consumer already makes.

Centralizes offset tracking, append-vs-replace, and end-of-list detection
for offset-based list endpoints on top of useOptimisticData and the
shared pagination helpers, so pages stop reimplementing the same logic
(and its off-by-one/end-of-list bugs) individually. Migrates BrowsePage
as the reference consumer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a reusable typed query hook wrapping useOptimisticData for paginated list endpoints

1 participant