diff --git a/docs/.vitepress/en.ts b/docs/.vitepress/en.ts
index b70514df7..90533ad77 100644
--- a/docs/.vitepress/en.ts
+++ b/docs/.vitepress/en.ts
@@ -76,6 +76,7 @@ export default defineConfig({
{ text: "useSuspenseQuery", link: "/use-suspense-query" },
{ text: "useInfiniteQuery", link: "/use-infinite-query" },
{ text: "queryOptions", link: "/query-options" },
+ { text: "infiniteQueryOptions", link: '/infinite-query-options'}
],
},
{
diff --git a/docs/openapi-react-query/infinite-query-options.md b/docs/openapi-react-query/infinite-query-options.md
new file mode 100644
index 000000000..25194a01a
--- /dev/null
+++ b/docs/openapi-react-query/infinite-query-options.md
@@ -0,0 +1,161 @@
+---
+title: infiniteQueryOptions
+---
+
+# {{ $frontmatter.title }}
+
+The `infiniteQueryOptions` method allows you to construct type-safe [Infinite Query Options](https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries).
+
+`infiniteQueryOptions` can be used together with `@tanstack/react-query` APIs that take infinite query options, such as
+[useInfiniteQuery](https://tanstack.com/query/latest/docs/framework/react/reference/useInfiniteQuery),
+[usePrefetchInfiniteQuery](https://tanstack.com/query/latest/docs/framework/react/reference/usePrefetchInfiniteQuery),
+[QueryClient.fetchInfiniteQuery](https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientfetchinfinitequery)
+among many others.
+
+If you would like to use an infinite query API that is not explicitly supported by `openapi-react-query`, this is the way to go.
+
+## Examples
+
+[useInfiniteQuery example](use-infinite-query#example) rewritten using `infiniteQueryOptions`.
+
+::: code-group
+
+```tsx [src/app.tsx]
+import { useInfiniteQuery } from '@tanstack/react-query';
+import { $api } from './api';
+
+const PostList = () => {
+ const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery(
+ $api.infiniteQueryOptions(
+ 'get',
+ '/posts',
+ {
+ params: {
+ query: {
+ limit: 10,
+ },
+ },
+ },
+ {
+ getNextPageParam: lastPage => lastPage.nextPage,
+ initialPageParam: 0,
+ }
+ )
+ );
+
+ return (
+
+ {data?.pages.map((page, i) => (
+
+ {page.items.map(post => (
+
{post.title}
+ ))}
+
+ ))}
+ {hasNextPage && (
+
+ )}
+
+ );
+};
+```
+
+```ts [src/api.ts]
+import createFetchClient from 'openapi-fetch';
+import createClient from 'openapi-react-query';
+import type { paths } from './my-openapi-3-schema'; // generated by openapi-typescript
+
+const fetchClient = createFetchClient({
+ baseUrl: 'https://myapi.dev/v1/',
+});
+export const $api = createClient(fetchClient);
+```
+
+:::
+
+::: info Good to Know
+
+[useInfiniteQuery](use-infinite-query) uses `infiniteQueryOptions` under the hood.
+
+:::
+
+Usage with `usePrefetchInfiniteQuery`.
+
+::: code-group
+
+```tsx [src/post-list.tsx]
+import { usePrefetchInfiniteQuery } from '@tanstack/react-query';
+import { $api } from './api';
+
+export const PostList = () => {
+ // Prefetch infinite query
+ usePrefetchInfiniteQuery(
+ $api.infiniteQueryOptions(
+ 'get',
+ '/posts',
+ {
+ params: {
+ query: {
+ limit: 10,
+ },
+ },
+ },
+ {
+ getNextPageParam: lastPage => lastPage.nextPage,
+ initialPageParam: 0,
+ pages: 3, // Prefetch first 3 pages
+ }
+ )
+ );
+
+ // ... rest of the component
+};
+```
+
+```ts [src/api.ts]
+import createFetchClient from 'openapi-fetch';
+import createClient from 'openapi-react-query';
+import type { paths } from './my-openapi-3-schema'; // generated by openapi-typescript
+
+const fetchClient = createFetchClient({
+ baseUrl: 'https://myapi.dev/v1/',
+});
+export const $api = createClient(fetchClient);
+```
+
+:::
+
+## Api
+
+```tsx
+const infiniteQueryOptions = $api.infiniteQueryOptions(method, path, options, infiniteQueryOptions);
+```
+
+**Arguments**
+
+- `method` **(required)**
+ - The HTTP method to use for the request.
+ - The method is used as key. See [Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) for more information.
+- `path` **(required)**
+ - The pathname to use for the request.
+ - Must be an available path for the given method in your schema.
+ - The pathname is used as key. See [Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) for more information.
+- `options`
+ - The fetch options to use for the request.
+ - Only required if the OpenApi schema requires parameters.
+ - The options `params` are used as key. See [Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) for more information.
+- `infiniteQueryOptions` **(required)**
+ - `pageParamName` The query param name used for pagination, `"cursor"` by default.
+ - `initialPageParam` **(required)**: The initial page param for the first page.
+ - `getNextPageParam`: Function to calculate the next page param.
+ - `getPreviousPageParam`: Function to calculate the previous page param.
+ - Additional infinite query options can be passed through.
+
+**Returns**
+
+- [Infinite Query Options](https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries)
+ - Fully typed thus `data` and `error` will be correctly deduced.
+ - `queryKey` is `[method, path, params]`.
+ - `queryFn` is set to a fetcher function that handles pagination.
diff --git a/packages/openapi-react-query/biome.json b/packages/openapi-react-query/biome.json
index fab77483f..b70618d3d 100644
--- a/packages/openapi-react-query/biome.json
+++ b/packages/openapi-react-query/biome.json
@@ -1,9 +1,9 @@
{
"root": false,
- "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
+ "$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"extends": "//",
"files": {
- "includes": ["**", "!dist/**", "!test/fixtures/**"]
+ "includes": ["**", "!dist", "!test/fixtures"]
},
"linter": {
"rules": {
diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts
index 337919ac3..d2ed452bc 100644
--- a/packages/openapi-react-query/src/index.ts
+++ b/packages/openapi-react-query/src/index.ts
@@ -79,6 +79,59 @@ export type QueryOptionsFunction;
+// Helper type to infer TPageParam type
+type InferPageParamType = T extends { initialPageParam: infer P } ? P : unknown;
+
+export type InfiniteQueryOptionsFunction<
+ Paths extends Record>,
+ Media extends MediaType,
+> = <
+ Method extends HttpMethod,
+ Path extends PathsWithMethod,
+ Init extends MaybeOptionalInit,
+ Response extends Required>,
+ Options extends Omit<
+ UseInfiniteQueryOptions<
+ Response["data"],
+ Response["error"],
+ InferSelectReturnType, Options["select"]>,
+ QueryKey,
+ InferPageParamType
+ >,
+ "queryKey" | "queryFn"
+ > & {
+ pageParamName?: string;
+ initialPageParam: InferPageParamType;
+ },
+>(
+ method: Method,
+ path: Path,
+ init: InitWithUnknowns,
+ options: Options,
+) => NoInfer<
+ Omit<
+ UseInfiniteQueryOptions<
+ Response["data"],
+ Response["error"],
+ InferSelectReturnType, Options["select"]>,
+ QueryKey,
+ InferPageParamType
+ >,
+ "queryFn"
+ > & {
+ queryFn: Exclude<
+ UseInfiniteQueryOptions<
+ Response["data"],
+ Response["error"],
+ InferSelectReturnType, Options["select"]>,
+ QueryKey,
+ InferPageParamType
+ >["queryFn"],
+ SkipToken | undefined
+ >;
+ }
+>;
+
export type UseQueryMethod>, Media extends MediaType> = <
Method extends HttpMethod,
Path extends PathsWithMethod,
@@ -112,11 +165,12 @@ export type UseInfiniteQueryMethod, Options["select"]>,
QueryKey,
- unknown
+ InferPageParamType
>,
"queryKey" | "queryFn"
> & {
pageParamName?: string;
+ initialPageParam: InferPageParamType;
},
>(
method: Method,
@@ -166,6 +220,7 @@ export type UseMutationMethod {
queryOptions: QueryOptionsFunction;
+ infiniteQueryOptions: InfiniteQueryOptionsFunction;
useQuery: UseQueryMethod;
useSuspenseQuery: UseSuspenseQueryMethod;
useInfiniteQuery: UseInfiniteQueryMethod;
@@ -214,44 +269,47 @@ export default function createClient = (method, path, init, options) => {
+ const { pageParamName = "cursor", initialPageParam, ...restOptions } = options;
+ const { queryKey } = queryOptions(method, path, init);
+
+ return {
+ queryKey,
+ initialPageParam,
+ queryFn: async ({ queryKey: [method, path, init], pageParam, signal }) => {
+ const mth = method.toUpperCase() as Uppercase;
+ const fn = client[mth] as ClientMethod;
+ const mergedInit = {
+ ...init,
+ signal,
+ params: {
+ ...(init?.params || {}),
+ query: {
+ ...(init?.params as { query?: DefaultParamsOption })?.query,
+ [pageParamName]: pageParam,
+ },
+ },
+ };
+
+ const { data, error } = await fn(path, mergedInit as any);
+ if (error) {
+ throw error;
+ }
+ return data;
+ },
+ ...restOptions,
+ };
+ };
+
return {
queryOptions,
+ infiniteQueryOptions,
useQuery: (method, path, ...[init, options, queryClient]) =>
useQuery(queryOptions(method, path, init as InitWithUnknowns, options), queryClient),
useSuspenseQuery: (method, path, ...[init, options, queryClient]) =>
useSuspenseQuery(queryOptions(method, path, init as InitWithUnknowns, options), queryClient),
- useInfiniteQuery: (method, path, init, options, queryClient) => {
- const { pageParamName = "cursor", ...restOptions } = options;
- const { queryKey } = queryOptions(method, path, init);
- return useInfiniteQuery(
- {
- queryKey,
- queryFn: async ({ queryKey: [method, path, init], pageParam = 0, signal }) => {
- const mth = method.toUpperCase() as Uppercase;
- const fn = client[mth] as ClientMethod;
- const mergedInit = {
- ...init,
- signal,
- params: {
- ...(init?.params || {}),
- query: {
- ...(init?.params as { query?: DefaultParamsOption })?.query,
- [pageParamName]: pageParam,
- },
- },
- };
-
- const { data, error } = await fn(path, mergedInit as any);
- if (error) {
- throw error;
- }
- return data;
- },
- ...restOptions,
- },
- queryClient,
- );
- },
+ useInfiniteQuery: (method, path, init, options, queryClient) =>
+ useInfiniteQuery(infiniteQueryOptions(method, path, init, options), queryClient),
useMutation: (method, path, options, queryClient) =>
useMutation(
{
diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx
index 4dcca2eee..3db6dc1ce 100644
--- a/packages/openapi-react-query/test/index.test.tsx
+++ b/packages/openapi-react-query/test/index.test.tsx
@@ -2,6 +2,7 @@ import {
QueryClient,
QueryClientProvider,
skipToken,
+ useInfiniteQuery,
useQueries,
useQuery,
useSuspenseQuery,
@@ -75,9 +76,87 @@ describe("client", () => {
expect(client).toHaveProperty("useQuery");
expect(client).toHaveProperty("useSuspenseQuery");
expect(client).toHaveProperty("useMutation");
+ if ("infiniteQueryOptions" in client) {
+ expect(client).toHaveProperty("infiniteQueryOptions");
+ }
});
describe("queryOptions", () => {
+ describe("infiniteQueryOptions", () => {
+ it("returns infinite query options that can be passed to useInfiniteQuery", async () => {
+ const fetchClient = createFetchClient({ baseUrl });
+ const client = createClient(fetchClient);
+
+ const options = client.infiniteQueryOptions(
+ "get",
+ "/paginated-data",
+ {
+ params: {
+ query: {
+ limit: 3,
+ },
+ },
+ },
+ {
+ getNextPageParam: (lastPage) => lastPage.nextPage,
+ initialPageParam: 0,
+ },
+ );
+
+ expect(options).toHaveProperty("queryKey");
+ expect(options).toHaveProperty("queryFn");
+ expect(Array.isArray(options.queryKey)).toBe(true);
+ expectTypeOf(options.queryFn).toBeFunction();
+
+ useMockRequestHandler({
+ baseUrl,
+ method: "get",
+ path: "/paginated-data",
+ status: 200,
+ body: { items: [1, 2, 3], nextPage: 1 },
+ });
+
+ const { result } = renderHook(() => useInfiniteQuery(options), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data?.pages[0].items).toEqual([1, 2, 3]);
+ });
+
+ it("returns infinite query options with custom pageParamName", async () => {
+ const fetchClient = createFetchClient({ baseUrl });
+ const client = createClient(fetchClient);
+
+ const options = client.infiniteQueryOptions(
+ "get",
+ "/paginated-data",
+ {
+ params: {
+ query: {
+ limit: 3,
+ },
+ },
+ },
+ {
+ getNextPageParam: (lastPage) => lastPage.nextPage,
+ initialPageParam: 0,
+ pageParamName: "follow_cursor",
+ },
+ );
+
+ useMockRequestHandler({
+ baseUrl,
+ method: "get",
+ path: "/paginated-data",
+ status: 200,
+ body: { items: [1, 2, 3], nextPage: 1 },
+ });
+
+ const { result } = renderHook(() => useInfiniteQuery(options), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data?.pages[0].items).toEqual([1, 2, 3]);
+ });
+ });
it("has correct parameter types", async () => {
const fetchClient = createFetchClient({ baseUrl });
const client = createClient(fetchClient);
@@ -158,7 +237,10 @@ describe("client", () => {
);
expectTypeOf(result.current[0].data).toEqualTypeOf();
- expectTypeOf(result.current[0].error).toEqualTypeOf<{ code: number; message: string } | null>();
+ expectTypeOf(result.current[0].error).toEqualTypeOf<{
+ code: number;
+ message: string;
+ } | null>();
expectTypeOf(result.current[1]).toEqualTypeOf<(typeof result.current)[0]>();
@@ -170,7 +252,10 @@ describe("client", () => {
}
| undefined
>();
- expectTypeOf(result.current[2].error).toEqualTypeOf<{ code: number; message: string } | null>();
+ expectTypeOf(result.current[2].error).toEqualTypeOf<{
+ code: number;
+ message: string;
+ } | null>();
expectTypeOf(result.current[3]).toEqualTypeOf<(typeof result.current)[2]>();
@@ -811,7 +896,9 @@ describe("client", () => {
wrapper,
});
- const data = await result.current.mutateAsync({ body: { message: "Hello", replied_at: 0 } });
+ const data = await result.current.mutateAsync({
+ body: { message: "Hello", replied_at: 0 },
+ });
expect(data.message).toBe("Hello");
});
@@ -1015,7 +1102,7 @@ describe("client", () => {
expect(firstRequestUrl?.searchParams.get("cursor")).toBe("0");
// Set up mock for second page before triggering next page fetch
- const secondRequestHandler = useMockRequestHandler({
+ useMockRequestHandler({
baseUrl,
method: "get",
path: "/paginated-data",
@@ -1142,7 +1229,7 @@ describe("client", () => {
const client = createClient(fetchClient);
// First page request handler
- const firstRequestHandler = useMockRequestHandler({
+ useMockRequestHandler({
baseUrl,
method: "get",
path: "/paginated-data",
@@ -1178,7 +1265,7 @@ describe("client", () => {
expect(result.current.data).toEqual([1, 2, 3]);
// Set up mock for second page before triggering next page fetch
- const secondRequestHandler = useMockRequestHandler({
+ useMockRequestHandler({
baseUrl,
method: "get",
path: "/paginated-data",