diff --git a/src/query/index.ts b/src/query/index.ts
index a4ed052..2da88b4 100644
--- a/src/query/index.ts
+++ b/src/query/index.ts
@@ -1 +1,2 @@
export * from './createQuery';
+export * from './usePaginatedQuery';
diff --git a/src/query/usePaginatedQuery.test.tsx b/src/query/usePaginatedQuery.test.tsx
new file mode 100644
index 0000000..6ad52d2
--- /dev/null
+++ b/src/query/usePaginatedQuery.test.tsx
@@ -0,0 +1,326 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, waitFor, act } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import React from 'react';
+import { createQuery } from './createQuery';
+import { usePaginatedQuery } from './usePaginatedQuery';
+
+function makeWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+interface Item {
+ id: number;
+ name: string;
+}
+
+function makeItems(count: number, offset = 0): Item[] {
+ return Array.from({ length: count }, (_, i) => ({
+ id: offset + i + 1,
+ name: `item-${offset + i + 1}`,
+ }));
+}
+
+const offsetFetcher = vi.fn(async (_params: { page: number; pageSize: number }) =>
+ makeItems(_params.pageSize, (_params.page - 1) * _params.pageSize),
+);
+
+const cursorFetcher = vi.fn(
+ async (_params: { cursor?: string | number | null | undefined }): Promise- => {
+ const start = _params.cursor ? Number(_params.cursor) : 0;
+ return makeItems(3, start);
+ },
+);
+
+const offsetQueryDef = createQuery(
+ (p: { page: number; pageSize: number }) => ['items', 'offset', p.page, p.pageSize] as const,
+ offsetFetcher,
+);
+
+const cursorQueryDef = createQuery(
+ (p: { cursor?: string | number | null | undefined }) => ['items', 'cursor', p.cursor] as const,
+ cursorFetcher,
+);
+
+// ---------------------------------------------------------------------------
+// Offset mode tests
+// ---------------------------------------------------------------------------
+
+describe('usePaginatedQuery — offset mode', () => {
+ beforeEach(() => vi.clearAllMocks());
+ it('returns mode: offset', async () => {
+ const { result } = renderHook(
+ () =>
+ usePaginatedQuery(
+ offsetQueryDef,
+ { page: 1, pageSize: 3 },
+ { mode: 'offset', pageSize: 3 },
+ ),
+ { wrapper: makeWrapper() },
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.mode).toBe('offset');
+ });
+
+ it('data is a flat array', async () => {
+ const { result } = renderHook(
+ () =>
+ usePaginatedQuery(
+ offsetQueryDef,
+ { page: 1, pageSize: 3 },
+ { mode: 'offset', pageSize: 3 },
+ ),
+ { wrapper: makeWrapper() },
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(Array.isArray(result.current.data)).toBe(true);
+ expect(result.current.data).toHaveLength(3);
+ });
+
+ it('starts at page 1 by default', async () => {
+ const { result } = renderHook(
+ () =>
+ usePaginatedQuery(
+ offsetQueryDef,
+ { page: 1, pageSize: 3 },
+ { mode: 'offset', pageSize: 3 },
+ ),
+ { wrapper: makeWrapper() },
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.page).toBe(1);
+ });
+
+ it('respects initialPage option', async () => {
+ const { result } = renderHook(
+ () =>
+ usePaginatedQuery(
+ offsetQueryDef,
+ { page: 2, pageSize: 3 },
+ { mode: 'offset', pageSize: 3, initialPage: 2 },
+ ),
+ { wrapper: makeWrapper() },
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.page).toBe(2);
+ });
+
+ it('pageSize defaults to 20', () => {
+ const { result } = renderHook(
+ () => usePaginatedQuery(offsetQueryDef, { page: 1, pageSize: 20 }, { mode: 'offset' }),
+ { wrapper: makeWrapper() },
+ );
+ expect(result.current.pageSize).toBe(20);
+ });
+
+ it('nextPage increments page and triggers refetch', async () => {
+ const { result } = renderHook(
+ () =>
+ usePaginatedQuery(
+ offsetQueryDef,
+ { page: 1, pageSize: 3 },
+ { mode: 'offset', pageSize: 3 },
+ ),
+ { wrapper: makeWrapper() },
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ act(() => result.current.nextPage());
+ await waitFor(() => expect(result.current.page).toBe(2));
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ // Page 2 items start at id 4
+ expect(result.current.data[0].id).toBe(4);
+ });
+
+ it('prevPage decrements page and does not go below 1', async () => {
+ const { result } = renderHook(
+ () =>
+ usePaginatedQuery(
+ offsetQueryDef,
+ { page: 2, pageSize: 3 },
+ { mode: 'offset', pageSize: 3, initialPage: 2 },
+ ),
+ { wrapper: makeWrapper() },
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ act(() => result.current.prevPage());
+ await waitFor(() => expect(result.current.page).toBe(1));
+
+ act(() => result.current.prevPage());
+ await waitFor(() => expect(result.current.page).toBe(1)); // floor at 1
+ });
+
+ it('exposes isLoading and isError and error', async () => {
+ const { result } = renderHook(
+ () =>
+ usePaginatedQuery(
+ offsetQueryDef,
+ { page: 1, pageSize: 3 },
+ { mode: 'offset', pageSize: 3 },
+ ),
+ { wrapper: makeWrapper() },
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.isError).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Cursor mode tests
+// ---------------------------------------------------------------------------
+
+describe('usePaginatedQuery — cursor mode', () => {
+ beforeEach(() => vi.clearAllMocks());
+ it('returns mode: cursor', async () => {
+ const { result } = renderHook(
+ () =>
+ usePaginatedQuery(
+ cursorQueryDef,
+ { cursor: undefined },
+ {
+ mode: 'cursor',
+ getCursor: (page) => (page.length > 0 ? page[page.length - 1].id : undefined),
+ },
+ ),
+ { wrapper: makeWrapper() },
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.mode).toBe('cursor');
+ });
+
+ it('data is a flat array of first page', async () => {
+ const { result } = renderHook(
+ () =>
+ usePaginatedQuery(
+ cursorQueryDef,
+ { cursor: undefined },
+ {
+ mode: 'cursor',
+ getCursor: (page) => (page.length > 0 ? page[page.length - 1].id : undefined),
+ },
+ ),
+ { wrapper: makeWrapper() },
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(Array.isArray(result.current.data)).toBe(true);
+ expect(result.current.data).toHaveLength(3);
+ expect(result.current.data[0].id).toBe(1);
+ });
+
+ it('fetchNextPage appends data correctly', async () => {
+ const { result } = renderHook(
+ () =>
+ usePaginatedQuery(
+ cursorQueryDef,
+ { cursor: undefined },
+ {
+ mode: 'cursor',
+ getCursor: (page) => (page.length > 0 ? page[page.length - 1].id : undefined),
+ },
+ ),
+ { wrapper: makeWrapper() },
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.data).toHaveLength(3);
+
+ await act(async () => {
+ result.current.fetchNextPage();
+ });
+ await waitFor(() => expect(result.current.data).toHaveLength(6));
+
+ // Second page starts after id 3
+ expect(result.current.data[3].id).toBe(4);
+ });
+
+ it('hasNextPage is true when getCursor returns a value', async () => {
+ const { result } = renderHook(
+ () =>
+ usePaginatedQuery(
+ cursorQueryDef,
+ { cursor: undefined },
+ {
+ mode: 'cursor',
+ getCursor: (page) => (page.length > 0 ? page[page.length - 1].id : undefined),
+ },
+ ),
+ { wrapper: makeWrapper() },
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.hasNextPage).toBe(true);
+ });
+
+ it('hasNextPage is false when getCursor returns undefined', async () => {
+ const { result } = renderHook(
+ () =>
+ usePaginatedQuery(
+ cursorQueryDef,
+ { cursor: undefined },
+ {
+ mode: 'cursor',
+ getCursor: () => undefined,
+ },
+ ),
+ { wrapper: makeWrapper() },
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.hasNextPage).toBe(false);
+ });
+
+ it('nextCursor reflects the getCursor result', async () => {
+ const { result } = renderHook(
+ () =>
+ usePaginatedQuery(
+ cursorQueryDef,
+ { cursor: undefined },
+ {
+ mode: 'cursor',
+ getCursor: (page) => (page.length > 0 ? page[page.length - 1].id : undefined),
+ },
+ ),
+ { wrapper: makeWrapper() },
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.nextCursor).toBe(3); // last item on first page
+ });
+
+ it('exposes isLoading and isError and error', async () => {
+ const { result } = renderHook(
+ () =>
+ usePaginatedQuery(
+ cursorQueryDef,
+ { cursor: undefined },
+ {
+ mode: 'cursor',
+ getCursor: () => undefined,
+ },
+ ),
+ { wrapper: makeWrapper() },
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.isError).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+});
diff --git a/src/query/usePaginatedQuery.ts b/src/query/usePaginatedQuery.ts
new file mode 100644
index 0000000..6127958
--- /dev/null
+++ b/src/query/usePaginatedQuery.ts
@@ -0,0 +1,151 @@
+import React from 'react';
+import {
+ useQuery as useTanstackQuery,
+ useInfiniteQuery as useTanstackInfiniteQuery,
+} from '@tanstack/react-query';
+import type { InfiniteData } from '@tanstack/react-query';
+import type { QueryDefinition } from './createQuery';
+
+// ---------------------------------------------------------------------------
+// Shared result fields
+// ---------------------------------------------------------------------------
+
+interface PaginatedBase {
+ data: TData[];
+ isLoading: boolean;
+ isFetching: boolean;
+ isError: boolean;
+ error: Error | null;
+}
+
+// ---------------------------------------------------------------------------
+// Offset mode
+// ---------------------------------------------------------------------------
+
+export interface OffsetPaginationOptions {
+ mode: 'offset';
+ pageSize?: number;
+ initialPage?: number;
+}
+
+export interface OffsetPaginatedResult extends PaginatedBase {
+ mode: 'offset';
+ page: number;
+ pageSize: number;
+ totalPages: number | undefined;
+ nextPage: () => void;
+ prevPage: () => void;
+}
+
+// ---------------------------------------------------------------------------
+// Cursor mode
+// ---------------------------------------------------------------------------
+
+export interface CursorPaginationOptions {
+ mode: 'cursor';
+ getCursor: (lastPage: TData[]) => string | number | null | undefined;
+}
+
+export interface CursorPaginatedResult extends PaginatedBase {
+ mode: 'cursor';
+ fetchNextPage: () => void;
+ hasNextPage: boolean;
+ nextCursor: string | number | null | undefined;
+}
+
+// ---------------------------------------------------------------------------
+// Overloads
+// ---------------------------------------------------------------------------
+
+export function usePaginatedQuery(
+ queryDef: QueryDefinition,
+ params: TParams,
+ options: OffsetPaginationOptions,
+): OffsetPaginatedResult;
+
+export function usePaginatedQuery(
+ queryDef: QueryDefinition,
+ params: TParams,
+ options: CursorPaginationOptions,
+): CursorPaginatedResult;
+
+// ---------------------------------------------------------------------------
+// Implementation
+// ---------------------------------------------------------------------------
+
+export function usePaginatedQuery(
+ queryDef: QueryDefinition,
+ params: TParams,
+ options: OffsetPaginationOptions | CursorPaginationOptions,
+): OffsetPaginatedResult | CursorPaginatedResult {
+ const isOffset = options.mode === 'offset';
+
+ // --- Offset state (only meaningful in offset mode) -----------------------
+ 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({
+ queryKey: [...queryDef.queryKey({ ...params, page, pageSize } as TParams), page, pageSize],
+ queryFn: () => queryDef.queryFn({ ...params, page, pageSize } as TParams),
+ enabled: isOffset,
+ placeholderData: (prev) => prev,
+ });
+
+ // --- Cursor: useInfiniteQuery --------------------------------------------
+ const getCursor = !isOffset
+ ? (options as CursorPaginationOptions).getCursor
+ : () => undefined;
+
+ const infiniteQuery = useTanstackInfiniteQuery<
+ TData[],
+ Error,
+ InfiniteData,
+ readonly unknown[],
+ string | number | null | undefined
+ >({
+ queryKey: queryDef.queryKey(params),
+ queryFn: ({ pageParam }) => queryDef.queryFn({ ...params, cursor: pageParam } as TParams),
+ initialPageParam: undefined,
+ getNextPageParam: (lastPage) => getCursor(lastPage) ?? undefined,
+ enabled: !isOffset,
+ });
+
+ // --- Offset result -------------------------------------------------------
+ if (isOffset) {
+ const rawData = offsetQuery.data ?? [];
+ return {
+ mode: 'offset',
+ data: rawData,
+ isLoading: offsetQuery.isLoading,
+ isFetching: offsetQuery.isFetching,
+ isError: offsetQuery.isError,
+ error: offsetQuery.error,
+ page,
+ pageSize,
+ totalPages: undefined,
+ nextPage: () => setPage((p) => p + 1),
+ prevPage: () => setPage((p) => Math.max(1, p - 1)),
+ };
+ }
+
+ // --- Cursor result -------------------------------------------------------
+ const pages = infiniteQuery.data?.pages ?? [];
+ const flatData = pages.flat();
+ const lastPage = pages[pages.length - 1] ?? [];
+ const nextCursor = getCursor(lastPage);
+
+ return {
+ mode: 'cursor',
+ data: flatData,
+ isLoading: infiniteQuery.isLoading,
+ isFetching: infiniteQuery.isFetching,
+ isError: infiniteQuery.isError,
+ error: infiniteQuery.error,
+ fetchNextPage: () => infiniteQuery.fetchNextPage(),
+ hasNextPage: infiniteQuery.hasNextPage,
+ nextCursor,
+ };
+}