diff --git a/docs/.vitepress/en.ts b/docs/.vitepress/en.ts
index b70514df7..c6f69347b 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: "mutationOptions", link: "/mutation-options" },
],
},
{
diff --git a/docs/openapi-react-query/mutation-options.md b/docs/openapi-react-query/mutation-options.md
new file mode 100644
index 000000000..a9fe5a6ee
--- /dev/null
+++ b/docs/openapi-react-query/mutation-options.md
@@ -0,0 +1,88 @@
+---
+title: mutationOptions
+---
+
+# {{ $frontmatter.title }}
+
+The `mutationOptions` method lets you build type-safe [Mutation Options](https://tanstack.com/query/latest/docs/framework/react/reference/mutationOptions) that plug directly into React Query APIs.
+
+Use it whenever you want to call `$api` mutations but still provide your own `useMutation` (or other mutation consumer) configuration. The helper wires up the correct `mutationKey`, generates a fetcher that calls your OpenAPI endpoint, and preserves the inferred `data` and `error` types.
+
+## Examples
+
+Rewriting the [useMutation example](use-mutation#example) to use `mutationOptions`.
+
+::: code-group
+
+```tsx [src/app.tsx]
+import { useMutation } from '@tanstack/react-query';
+import { $api } from './api';
+
+export const App = () => {
+ const updateUser = useMutation(
+ $api.mutationOptions('patch', '/users/{user_id}', {
+ onSuccess: (data, variables) => {
+ console.log('Updated', variables.params?.path?.user_id, data.firstname);
+ },
+ })
+ );
+
+ return (
+
+ );
+};
+```
+
+```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
+
+`$api.useMutation` uses the same fetcher and key contract as `mutationOptions`. Reach for `mutationOptions` when you need to share mutation configuration across components or call React Query utilities such as `queryClient.mutationDefaults`.
+
+:::
+
+## API
+
+```tsx
+const options = $api.mutationOptions(method, path, mutationOptions);
+```
+
+**Arguments**
+
+- `method` **(required)**
+ - HTTP method of the OpenAPI operation.
+ - Also used as the first element of the mutation key.
+- `path` **(required)**
+ - Pathname of the OpenAPI operation.
+ - Must be valid for the given method in your generated schema.
+ - Used as the second element of the mutation key.
+- `mutationOptions`
+ - Optional `UseMutationOptions` for React Query.
+ - You can set callbacks (`onSuccess`, `onSettled`, …), retry behaviour, and every option except `mutationKey` and `mutationFn` (those are provided for you).
+
+**Returns**
+
+- [Mutation Options](https://tanstack.com/query/latest/docs/framework/react/reference/mutationOptions)
+ - `mutationKey` is `[method, path]`.
+ - `mutationFn` is a strongly typed fetcher that calls `openapi-fetch` with your `init` payload.
+ - `data` and `error` types match the OpenAPI schema, so `variables` inside callbacks are typed as the request shape.
diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts
index 337919ac3..9e916e6fb 100644
--- a/packages/openapi-react-query/src/index.ts
+++ b/packages/openapi-react-query/src/index.ts
@@ -37,6 +37,8 @@ export type QueryKey<
Init = MaybeOptionalInit,
> = Init extends undefined ? readonly [Method, Path] : readonly [Method, Path, Init];
+export type MutationKey = readonly [Method, Path];
+
export type QueryOptionsFunction>, Media extends MediaType> = <
Method extends HttpMethod,
Path extends PathsWithMethod,
@@ -79,6 +81,75 @@ export type QueryOptionsFunction;
+export type MutationOptionsFunction>, Media extends MediaType> = <
+ Method extends HttpMethod,
+ Path extends PathsWithMethod,
+ Init extends MaybeOptionalInit,
+ Response extends Required>,
+ Options extends Omit, "mutationKey" | "mutationFn">,
+>(
+ method: Method,
+ path: Path,
+ options?: Options,
+) => NoInfer<
+ Omit, "mutationFn"> & {
+ mutationFn: Exclude["mutationFn"], undefined>;
+ }
+>;
+
+// 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,
@@ -166,6 +237,7 @@ export type UseMutationMethod {
queryOptions: QueryOptionsFunction;
+ mutationOptions: MutationOptionsFunction;
useQuery: UseQueryMethod;
useSuspenseQuery: UseSuspenseQueryMethod;
useInfiniteQuery: UseInfiniteQueryMethod;
@@ -204,6 +276,22 @@ export default function createClient>(
+ method: Method,
+ path: Path,
+ ) => {
+ return async (init: any) => {
+ const mth = method.toUpperCase() as Uppercase;
+ const fn = client[mth] as ClientMethod;
+ const { data, error } = await fn(path, init as any);
+ if (error) {
+ throw error;
+ }
+
+ return data as Exclude;
+ };
+ };
+
const queryOptions: QueryOptionsFunction = (method, path, ...[init, options]) => ({
queryKey: (init === undefined ? ([method, path] as const) : ([method, path, init] as const)) as QueryKey<
Paths,
@@ -214,8 +302,15 @@ export default function createClient = (method, path, options) => ({
+ mutationKey: [method, path] as MutationKey,
+ mutationFn: createMutationFn(method, path),
+ ...options,
+ });
+
return {
queryOptions,
+ mutationOptions,
useQuery: (method, path, ...[init, options, queryClient]) =>
useQuery(queryOptions(method, path, init as InitWithUnknowns, options), queryClient),
useSuspenseQuery: (method, path, ...[init, options, queryClient]) =>
@@ -256,16 +351,7 @@ export default function createClient {
- const mth = method.toUpperCase() as Uppercase;
- const fn = client[mth] as ClientMethod;
- const { data, error } = await fn(path, init as InitWithUnknowns);
- if (error) {
- throw error;
- }
-
- return data as Exclude;
- },
+ mutationFn: createMutationFn(method, path),
...options,
},
queryClient,
diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx
index 4dcca2eee..b43ea718d 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,
+ useMutation,
useQueries,
useQuery,
useSuspenseQuery,
@@ -10,7 +11,7 @@ import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-li
import createFetchClient from "openapi-fetch";
import { type ReactNode, Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
-import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { afterAll, afterEach, beforeAll, describe, expect, expectTypeOf, it, vi } from "vitest";
import createClient, { type MethodResponse } from "../src/index.js";
import type { paths } from "./fixtures/api.js";
import { baseUrl, server, useMockRequestHandler } from "./fixtures/mock-server.js";
@@ -71,6 +72,7 @@ describe("client", () => {
it("generates all proper functions", () => {
const fetchClient = createFetchClient({ baseUrl });
const client = createClient(fetchClient);
+ expect(client).toHaveProperty("mutationOptions");
expect(client).toHaveProperty("queryOptions");
expect(client).toHaveProperty("useQuery");
expect(client).toHaveProperty("useSuspenseQuery");
@@ -643,6 +645,188 @@ describe("client", () => {
expect(signalPassedToFetch?.aborted).toBeTruthy();
});
});
+ describe("mutationOptions", () => {
+ it("has correct parameter types", async () => {
+ const fetchClient = createFetchClient({ baseUrl });
+ const client = createClient(fetchClient);
+
+ client.mutationOptions("put", "/comment");
+ // @ts-expect-error: Wrong method.
+ client.mutationOptions("get", "/comment");
+ // @ts-expect-error: Wrong path.
+ client.mutationOptions("put", "/commentX");
+ // @ts-expect-error: Missing required body param.
+ client.mutationOptions("post", "/blogposts/{post_id}/comment", {});
+ });
+
+ it("returns mutation options that can be passed to useMutation", async () => {
+ const fetchClient = createFetchClient({ baseUrl });
+ const client = createClient(fetchClient);
+
+ useMockRequestHandler({
+ baseUrl,
+ method: "put",
+ path: "/comment",
+ status: 200,
+ body: { message: "Hello World" },
+ });
+
+ const options = client.mutationOptions("put", "/comment");
+
+ expect(options).toHaveProperty("mutationKey");
+ expect(options).toHaveProperty("mutationFn");
+ expect(Array.isArray(options.mutationKey)).toBe(true);
+ expectTypeOf(options.mutationFn).toBeFunction();
+
+ const { result } = renderHook(() => useMutation(options), { wrapper });
+
+ result.current.mutate({ body: { message: "Hello World", replied_at: 123456789 } });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data?.message).toBe("Hello World");
+ });
+
+ it("returns mutation options that can resolve data correctly with mutateAsync", async () => {
+ const response = { message: "Updated successfully" };
+ const fetchClient = createFetchClient({ baseUrl });
+ const client = createClient(fetchClient);
+
+ useMockRequestHandler({
+ baseUrl,
+ method: "put",
+ path: "/comment",
+ status: 200,
+ body: response,
+ });
+
+ const options = client.mutationOptions("put", "/comment");
+ const { result } = renderHook(() => useMutation(options), { wrapper });
+
+ const data = await result.current.mutateAsync({
+ body: { message: "Test message", replied_at: 123456789 },
+ });
+
+ expectTypeOf(data).toEqualTypeOf<{
+ message: string;
+ }>();
+
+ expect(data).toEqual(response);
+ });
+
+ it("returns mutation options that handle error responses correctly", async () => {
+ const fetchClient = createFetchClient({ baseUrl });
+ const client = createClient(fetchClient);
+
+ useMockRequestHandler({
+ baseUrl,
+ method: "put",
+ path: "/comment",
+ status: 500,
+ body: { code: 500, message: "Internal Server Error" },
+ });
+
+ const options = client.mutationOptions("put", "/comment");
+ const { result } = renderHook(() => useMutation(options), { wrapper });
+
+ result.current.mutate({ body: { message: "Test message", replied_at: 123456789 } });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ expect(result.current.error?.message).toBe("Internal Server Error");
+ expect(result.current.data).toBeUndefined();
+ });
+
+ it("returns mutation options with path parameters", async () => {
+ const fetchClient = createFetchClient({ baseUrl });
+ const client = createClient(fetchClient);
+
+ useMockRequestHandler({
+ baseUrl,
+ method: "put",
+ path: "/blogposts",
+ status: 201,
+ body: { status: "Comment Created" },
+ });
+
+ const options = client.mutationOptions("put", "/blogposts");
+ const { result } = renderHook(() => useMutation(options), { wrapper });
+
+ result.current.mutate({
+ body: {
+ body: "Post test",
+ title: "Post Create",
+ publish_date: 3333333,
+ },
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data?.status).toBe("Comment Created");
+ });
+
+ it("returns mutation options that handle null response body", async () => {
+ const fetchClient = createFetchClient({ baseUrl });
+ const client = createClient(fetchClient);
+
+ useMockRequestHandler({
+ baseUrl,
+ method: "delete",
+ path: "/blogposts/:post_id",
+ status: 204,
+ body: null,
+ });
+
+ const options = client.mutationOptions("delete", "/blogposts/{post_id}");
+ const { result } = renderHook(() => useMutation(options), { wrapper });
+
+ result.current.mutate({
+ params: {
+ path: {
+ post_id: "1",
+ },
+ },
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.error).toBeNull();
+ });
+
+ it("returns mutation options that can be used with custom mutation options", async () => {
+ const fetchClient = createFetchClient({ baseUrl });
+ const client = createClient(fetchClient);
+
+ useMockRequestHandler({
+ baseUrl,
+ method: "put",
+ path: "/comment",
+ status: 200,
+ body: { message: "Success" },
+ });
+
+ const onSuccessSpy = vi.fn();
+ const onErrorSpy = vi.fn();
+
+ const options = {
+ ...client.mutationOptions("put", "/comment"),
+ onSuccess: onSuccessSpy,
+ onError: onErrorSpy,
+ };
+
+ const { result } = renderHook(() => useMutation(options), { wrapper });
+
+ result.current.mutate({ body: { message: "Test", replied_at: 123456789 } });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(onSuccessSpy).toHaveBeenNthCalledWith(
+ 1,
+ { message: "Success" },
+ { body: { message: "Test", replied_at: 123456789 } },
+ undefined,
+ expect.objectContaining({
+ mutationKey: ["put", "/comment"],
+ }),
+ );
+ expect(onErrorSpy).not.toHaveBeenCalled();
+ });
+ });
describe("useMutation", () => {
describe("mutate", () => {