feat(COMPT-41): implement usePaginatedQuery offset and cursor modes#4
Conversation
- usePaginatedQuery(queryDef, params, options) supports mode: 'offset' | 'cursor' - Offset mode: page/pageSize (default 20)/nextPage/prevPage/totalPages - Cursor mode: fetchNextPage/hasNextPage/nextCursor via useInfiniteQuery - Both expose data as flat T[] array, isLoading, isFetching, isError, error - Offset uses useQuery with page in queryKey; cursor uses useInfiniteQuery - getCursor option required for cursor mode - Typed overloads: full inference, no TanStack internals exposed - 15 tests, 100% coverage on usePaginatedQuery.ts, 95.62% overall Closes COMPT-41
There was a problem hiding this comment.
Pull request overview
Adds a new usePaginatedQuery hook to the query utilities, providing a unified pagination API that supports both offset- and cursor-based pagination on top of TanStack Query.
Changes:
- Introduces
usePaginatedQuerywith overloads formode: 'offset' | 'cursor', returning flatteneddataplus pagination helpers. - Implements offset pagination via
useQueryand cursor pagination viauseInfiniteQuery. - Adds a comprehensive test suite for the new hook and re-exports it from the query barrel.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| src/query/usePaginatedQuery.ts | Implements the new pagination hook with offset and cursor modes and typed result shapes. |
| src/query/usePaginatedQuery.test.tsx | Adds unit tests covering both modes and common pagination behaviors. |
| src/query/index.ts | Re-exports usePaginatedQuery so it becomes part of the package’s public API via src/index.ts. |
| const pageSize = isOffset ? ((options as OffsetPaginationOptions).pageSize ?? 20) : 20; | ||
| const [page, setPage] = React.useState( | ||
| isOffset ? ((options as OffsetPaginationOptions).initialPage ?? 1) : 1, | ||
| ); | ||
|
|
||
| // --- Offset: useQuery ---------------------------------------------------- | ||
| const offsetQuery = useTanstackQuery<TData[], Error>({ | ||
| queryKey: [...queryDef.queryKey({ ...params, page, pageSize } as TParams), page, pageSize], | ||
| queryFn: () => queryDef.queryFn({ ...params, page, pageSize } as TParams), |
There was a problem hiding this comment.
params is typed as unconstrained TParams, but the implementation relies on object spread ({ ...params, ... }) and then casts back to TParams. This can break at runtime (e.g., if a query definition uses a primitive param like string/number, queryFn will receive an object instead) and also undermines type safety. Consider constraining TParams to an object type (e.g. TParams extends Record<string, unknown>) and/or splitting “base params” from pagination params in the overloads so queryDef can be typed to accept TBaseParams & { page/pageSize | cursor } without casts.
| const offsetQuery = useTanstackQuery<TData[], Error>({ | ||
| queryKey: [...queryDef.queryKey({ ...params, page, pageSize } as TParams), page, pageSize], | ||
| queryFn: () => queryDef.queryFn({ ...params, page, pageSize } as TParams), |
There was a problem hiding this comment.
The offset queryKey appends page/pageSize after already calling queryDef.queryKey with merged { ...params, page, pageSize }. If the queryDef key already includes these fields (as in the tests), the key will contain duplicates; more importantly, it prevents cache sharing with queryDef.useQuery(mergedParams) because the keys no longer match. Prefer using exactly queryDef.queryKey(mergedParams) (or alternatively queryDef.queryKey(params) + page/pageSize once) to keep key composition consistent.
| const offsetQuery = useTanstackQuery<TData[], Error>({ | |
| queryKey: [...queryDef.queryKey({ ...params, page, pageSize } as TParams), page, pageSize], | |
| queryFn: () => queryDef.queryFn({ ...params, page, pageSize } as TParams), | |
| const offsetParams = { ...params, page, pageSize } as TParams; | |
| const offsetQuery = useTanstackQuery<TData[], Error>({ | |
| queryKey: queryDef.queryKey(offsetParams), | |
| queryFn: () => queryDef.queryFn(offsetParams), |
| error: offsetQuery.error, | ||
| page, | ||
| pageSize, | ||
| totalPages: undefined, |
There was a problem hiding this comment.
totalPages is part of the offset result but is currently always returned as undefined. This makes the API misleading (and appears to conflict with the PR description). Either implement a way to compute/provide totalPages (e.g., via an option or by supporting a query result shape that includes total count) or remove/rename the field so consumers don’t rely on a value that can never be present.
| totalPages: undefined, |
| fetchNextPage: () => infiniteQuery.fetchNextPage(), | ||
| hasNextPage: infiniteQuery.hasNextPage, | ||
| nextCursor, |
There was a problem hiding this comment.
fetchNextPage is exposed as () => void, but it actually returns a Promise from TanStack Query. Typing it as void prevents consumers from awaiting pagination (useful for UI flows and tests). Consider exposing it as () => Promise<unknown> (or a small custom promise type) to keep TanStack internals hidden while still allowing await fetchNextPage().
* ops: updated sonar variable * Feat/compt 40 create query factory (#3) * feat(COMPT-40): implement createQuery factory - Add createQuery(keyFn, fetcher) returning QueryDefinition<TParams, TData> - TData and TParams fully inferred from fetcher signature, zero manual annotation - queryKey returns stable readonly tuple via keyFn - useQuery shorthand hook wraps useTanstackQuery with typed params - Export from src/index.ts - Add @tanstack/react-query as peerDependency (>=5) and devDependency - Add pnpm cssstyle override to fix Node.js v22 + jsdom@28 ESM compat issue - Fix duplicate import in vitest.config.ts - 9 tests, 100% coverage on createQuery.ts, 88.6% overall Closes COMPT-40 * chore: sync package-lock.json with @tanstack/react-query addition * chore: switch from pnpm to npm, sync lockfile * chore: fix prettier formatting across all files * feat(COMPT-41): implement usePaginatedQuery offset and cursor modes (#4) - usePaginatedQuery(queryDef, params, options) supports mode: 'offset' | 'cursor' - Offset mode: page/pageSize (default 20)/nextPage/prevPage/totalPages - Cursor mode: fetchNextPage/hasNextPage/nextCursor via useInfiniteQuery - Both expose data as flat T[] array, isLoading, isFetching, isError, error - Offset uses useQuery with page in queryKey; cursor uses useInfiniteQuery - getCursor option required for cursor mode - Typed overloads: full inference, no TanStack internals exposed - 15 tests, 100% coverage on usePaginatedQuery.ts, 95.62% overall Closes COMPT-41 * feat(COMPT-42): implement createMutation and typed cache helpers (#5) * feat(COMPT-42): implement createMutation and typed cache helpers - createMutation(fn) returns MutationDefinition with mutationFn and useMutation shorthand - useMutation exposes mutate/mutateAsync/isPending/isError/error/data/reset - invalidateQueries(client, queryDef, params?) uses queryDef key — no raw strings - setQueryData typed updater — wrong shape is TypeScript compile error - All exported from src/index.ts via src/query/index.ts - 18 tests (10 mutation + 8 cache), 100% coverage on both src files, 95.94% overall Closes COMPT-42 * chore: fix prettier formatting * fix: suppress eslint no-unused-vars on intentionally unused mutation param * feat(COMPT-43): add integration test suite in src/__tests__/ (#6) - createQuery.test.tsx: queryKey shape, queryFn call, useQuery loading/success/error/enabled/rerender - usePaginatedQuery.test.tsx: offset page navigation, data shape, cursor fetchNextPage/hasNextPage/nextCursor - createMutation.test.tsx: idle state, mutate, isPending, data, isError, reset, mutateAsync - cacheHelpers.test.tsx: invalidateQueries marks stale + refetch, setQueryData direct/updater/hook reflect All 84 tests pass, 97.35% stmt coverage (target: 85%) * feat(COMPT-43): consolidate all tests into src/__tests__/ (#7) - Moved all co-located tests (src/query/*.test.tsx, src/index.test.ts) into src/__tests__/ - Merged unique tests from co-located files: definition shape, stable key, TData inference, mode assertions, initialPage, mutationFn direct call - Deleted: src/query/createQuery.test.tsx, cacheHelpers.test.tsx, createMutation.test.tsx, usePaginatedQuery.test.tsx, src/index.test.ts - 51 tests, all passing, no test files outside src/__tests__/ * feat(COMPT-44): README guide + changeset for v0.1.0 (#8) - Rewrote README as an end-to-end usage guide for @ciscode/query-kit - createQuery: key builder, fetcher, useQuery shorthand, direct key/fn access - usePaginatedQuery: offset mode (nextPage/prevPage) and cursor mode (fetchNextPage/hasNextPage) - createMutation + invalidateQueries full lifecycle example - setQueryData typed updater example - API reference table covering all exports - Peer dep @tanstack/react-query >=5 clearly stated - Changeset: minor bump to v0.1.0 (initial public release) * Feat/compt 44 readme changeset (#10) * feat(COMPT-44): README guide + changeset for v0.1.0 - Rewrote README as an end-to-end usage guide for @ciscode/query-kit - createQuery: key builder, fetcher, useQuery shorthand, direct key/fn access - usePaginatedQuery: offset mode (nextPage/prevPage) and cursor mode (fetchNextPage/hasNextPage) - createMutation + invalidateQueries full lifecycle example - setQueryData typed updater example - API reference table covering all exports - Peer dep @tanstack/react-query >=5 clearly stated - Changeset: minor bump to v0.1.0 (initial public release) * fix(ci): correct sonar.tests path from 'test' to 'src/__tests__' Tests live in src/__tests__/, not test/. Also add sonar.exclusions and sonar.test.inclusions so source files and test files are correctly separated in SonarCloud analysis. --------- Co-authored-by: Zaiidmo <zaiidmoumnii@gmail.com>
* ops: updated sonar variable * Feat/compt 40 create query factory (#3) * feat(COMPT-40): implement createQuery factory - Add createQuery(keyFn, fetcher) returning QueryDefinition<TParams, TData> - TData and TParams fully inferred from fetcher signature, zero manual annotation - queryKey returns stable readonly tuple via keyFn - useQuery shorthand hook wraps useTanstackQuery with typed params - Export from src/index.ts - Add @tanstack/react-query as peerDependency (>=5) and devDependency - Add pnpm cssstyle override to fix Node.js v22 + jsdom@28 ESM compat issue - Fix duplicate import in vitest.config.ts - 9 tests, 100% coverage on createQuery.ts, 88.6% overall Closes COMPT-40 * chore: sync package-lock.json with @tanstack/react-query addition * chore: switch from pnpm to npm, sync lockfile * chore: fix prettier formatting across all files * feat(COMPT-41): implement usePaginatedQuery offset and cursor modes (#4) - usePaginatedQuery(queryDef, params, options) supports mode: 'offset' | 'cursor' - Offset mode: page/pageSize (default 20)/nextPage/prevPage/totalPages - Cursor mode: fetchNextPage/hasNextPage/nextCursor via useInfiniteQuery - Both expose data as flat T[] array, isLoading, isFetching, isError, error - Offset uses useQuery with page in queryKey; cursor uses useInfiniteQuery - getCursor option required for cursor mode - Typed overloads: full inference, no TanStack internals exposed - 15 tests, 100% coverage on usePaginatedQuery.ts, 95.62% overall Closes COMPT-41 * feat(COMPT-42): implement createMutation and typed cache helpers (#5) * feat(COMPT-42): implement createMutation and typed cache helpers - createMutation(fn) returns MutationDefinition with mutationFn and useMutation shorthand - useMutation exposes mutate/mutateAsync/isPending/isError/error/data/reset - invalidateQueries(client, queryDef, params?) uses queryDef key — no raw strings - setQueryData typed updater — wrong shape is TypeScript compile error - All exported from src/index.ts via src/query/index.ts - 18 tests (10 mutation + 8 cache), 100% coverage on both src files, 95.94% overall Closes COMPT-42 * chore: fix prettier formatting * fix: suppress eslint no-unused-vars on intentionally unused mutation param * feat(COMPT-43): add integration test suite in src/__tests__/ (#6) - createQuery.test.tsx: queryKey shape, queryFn call, useQuery loading/success/error/enabled/rerender - usePaginatedQuery.test.tsx: offset page navigation, data shape, cursor fetchNextPage/hasNextPage/nextCursor - createMutation.test.tsx: idle state, mutate, isPending, data, isError, reset, mutateAsync - cacheHelpers.test.tsx: invalidateQueries marks stale + refetch, setQueryData direct/updater/hook reflect All 84 tests pass, 97.35% stmt coverage (target: 85%) * feat(COMPT-43): consolidate all tests into src/__tests__/ (#7) - Moved all co-located tests (src/query/*.test.tsx, src/index.test.ts) into src/__tests__/ - Merged unique tests from co-located files: definition shape, stable key, TData inference, mode assertions, initialPage, mutationFn direct call - Deleted: src/query/createQuery.test.tsx, cacheHelpers.test.tsx, createMutation.test.tsx, usePaginatedQuery.test.tsx, src/index.test.ts - 51 tests, all passing, no test files outside src/__tests__/ * feat(COMPT-44): README guide + changeset for v0.1.0 (#8) - Rewrote README as an end-to-end usage guide for @ciscode/query-kit - createQuery: key builder, fetcher, useQuery shorthand, direct key/fn access - usePaginatedQuery: offset mode (nextPage/prevPage) and cursor mode (fetchNextPage/hasNextPage) - createMutation + invalidateQueries full lifecycle example - setQueryData typed updater example - API reference table covering all exports - Peer dep @tanstack/react-query >=5 clearly stated - Changeset: minor bump to v0.1.0 (initial public release) * Feat/compt 44 readme changeset (#10) * feat(COMPT-44): README guide + changeset for v0.1.0 - Rewrote README as an end-to-end usage guide for @ciscode/query-kit - createQuery: key builder, fetcher, useQuery shorthand, direct key/fn access - usePaginatedQuery: offset mode (nextPage/prevPage) and cursor mode (fetchNextPage/hasNextPage) - createMutation + invalidateQueries full lifecycle example - setQueryData typed updater example - API reference table covering all exports - Peer dep @tanstack/react-query >=5 clearly stated - Changeset: minor bump to v0.1.0 (initial public release) * fix(ci): correct sonar.tests path from 'test' to 'src/__tests__' Tests live in src/__tests__/, not test/. Also add sonar.exclusions and sonar.test.inclusions so source files and test files are correctly separated in SonarCloud analysis. * Feat/compt 44 readme changeset (#12) * feat(COMPT-44): README guide + changeset for v0.1.0 - Rewrote README as an end-to-end usage guide for @ciscode/query-kit - createQuery: key builder, fetcher, useQuery shorthand, direct key/fn access - usePaginatedQuery: offset mode (nextPage/prevPage) and cursor mode (fetchNextPage/hasNextPage) - createMutation + invalidateQueries full lifecycle example - setQueryData typed updater example - API reference table covering all exports - Peer dep @tanstack/react-query >=5 clearly stated - Changeset: minor bump to v0.1.0 (initial public release) * fix(ci): correct sonar.tests path from 'test' to 'src/__tests__' Tests live in src/__tests__/, not test/. Also add sonar.exclusions and sonar.test.inclusions so source files and test files are correctly separated in SonarCloud analysis. * 0.0.1 --------- Co-authored-by: Zaiidmo <zaiidmoumnii@gmail.com>
Closes COMPT-41
Summary
Why
Checklist
npm run lintpassesnpm run typecheckpassesnpm testpassesnpm run buildpassesnpx changeset) if this affects consumersNotes