diff --git a/.changeset/icy-cities-speak.md b/.changeset/icy-cities-speak.md
new file mode 100644
index 00000000000..b02c932197d
--- /dev/null
+++ b/.changeset/icy-cities-speak.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/react-query': minor
+---
+
+feat(react-query): backport mutationOptions in v4
diff --git a/packages/react-query/src/__tests__/mutationOptions.test.tsx b/packages/react-query/src/__tests__/mutationOptions.test.tsx
new file mode 100644
index 00000000000..1a1af7c97f7
--- /dev/null
+++ b/packages/react-query/src/__tests__/mutationOptions.test.tsx
@@ -0,0 +1,140 @@
+import * as React from 'react'
+import { QueryClient } from '@tanstack/query-core'
+import { fireEvent, waitFor } from '@testing-library/react'
+import { mutationOptions } from '../mutationOptions'
+import { useIsMutating, useMutation } from '..'
+import { renderWithClient, sleep } from './utils'
+import type { UseMutationOptions } from '../types'
+
+describe('mutationOptions', () => {
+ it('should return the object received as a parameter without any modification (with mutationKey)', () => {
+ const object: UseMutationOptions = {
+ mutationKey: ['key'],
+ mutationFn: () => Promise.resolve(5),
+ } as const
+
+ expect(mutationOptions(object)).toBe(object)
+ })
+
+ it('should return the object received as a parameter without any modification (without mutationKey)', () => {
+ const object: UseMutationOptions = {
+ mutationFn: () => Promise.resolve(5),
+ } as const
+
+ expect(mutationOptions(object)).toBe(object)
+ })
+
+ it('should work with useMutation (with mutationKey)', async () => {
+ const queryClient = new QueryClient()
+ const mutationOpts = mutationOptions({
+ mutationKey: ['key'],
+ mutationFn: () => sleep(10).then(() => 'data'),
+ })
+
+ function Page() {
+ const mutation = useMutation(mutationOpts)
+
+ return (
+
+
+ {mutation.data ?? 'empty'}
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.getByText('empty')).toBeTruthy()
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
+ await waitFor(() => rendered.getByText('data'))
+ })
+
+ it('should work with useMutation (without mutationKey)', async () => {
+ const queryClient = new QueryClient()
+ const mutationOpts = mutationOptions({
+ mutationFn: () => sleep(10).then(() => 'data'),
+ })
+
+ function Page() {
+ const mutation = useMutation(mutationOpts)
+
+ return (
+
+
+ {mutation.data ?? 'empty'}
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.getByText('empty')).toBeTruthy()
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
+ await waitFor(() => rendered.getByText('data'))
+ })
+
+ it('should work with useIsMutating filtering by mutationKey', async () => {
+ const queryClient = new QueryClient()
+ const mutationOpts1 = mutationOptions({
+ mutationKey: ['key1'],
+ mutationFn: () => sleep(50).then(() => 'data1'),
+ })
+ const mutationOpts2 = mutationOptions({
+ mutationKey: ['key2'],
+ mutationFn: () => sleep(50).then(() => 'data2'),
+ })
+
+ function Page() {
+ const isMutating = useIsMutating({
+ mutationKey: mutationOpts1.mutationKey,
+ })
+ const { mutate: mutate1 } = useMutation(mutationOpts1)
+ const { mutate: mutate2 } = useMutation(mutationOpts2)
+
+ return (
+
+ isMutating: {isMutating}
+
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ rendered.getByText('isMutating: 0')
+ fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
+ fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
+ await waitFor(() => rendered.getByText('isMutating: 1'))
+ await waitFor(() => rendered.getByText('isMutating: 0'))
+ })
+
+ it('should work with queryClient.isMutating', async () => {
+ const queryClient = new QueryClient()
+ const mutationOpts = mutationOptions({
+ mutationKey: ['mutation'],
+ mutationFn: () => sleep(10).then(() => 'data'),
+ })
+
+ function Page() {
+ const isMutating = queryClient.isMutating({
+ mutationKey: mutationOpts.mutationKey,
+ })
+ const { mutate } = useMutation(mutationOpts)
+
+ return (
+
+ isMutating: {isMutating}
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ rendered.getByText('isMutating: 0')
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
+ await waitFor(() => rendered.getByText('isMutating: 1'))
+ await waitFor(() => rendered.getByText('isMutating: 0'))
+ })
+})
diff --git a/packages/react-query/src/__tests__/mutationOptions.types.test.tsx b/packages/react-query/src/__tests__/mutationOptions.types.test.tsx
new file mode 100644
index 00000000000..0cf877cbb8d
--- /dev/null
+++ b/packages/react-query/src/__tests__/mutationOptions.types.test.tsx
@@ -0,0 +1,438 @@
+import { expectTypeOf } from 'expect-type'
+import { QueryClient } from '@tanstack/query-core'
+import {
+ type UseMutationOptions,
+ type UseMutationResult,
+ mutationOptions,
+ useIsMutating,
+ useMutation,
+ useQueryClient,
+} from '..'
+import { doNotExecute } from './utils'
+import type { MutationKey, OmitKeyof, WithRequired } from '@tanstack/query-core'
+
+const mutationKey = ['key'] as const
+const mutationFn = (_input: { id: string }) =>
+ Promise.resolve({ field: 'success' })
+
+describe('mutationOptions', () => {
+ it('should not allow excess properties', () => {
+ doNotExecute(() => {
+ // @ts-expect-error this is a good error, because onMutates does not exist!
+ mutationOptions({
+ mutationFn: () => Promise.resolve(5),
+ mutationKey: ['key'],
+ onMutates: 1000,
+ onSuccess: (data) => {
+ expectTypeOf(data).toEqualTypeOf()
+ },
+ })
+ })
+ })
+
+ it('should infer types for callbacks', () => {
+ doNotExecute(() => {
+ mutationOptions({
+ mutationFn: () => Promise.resolve(5),
+ mutationKey: ['key'],
+ onSuccess: (data) => {
+ expectTypeOf(data).toEqualTypeOf()
+ },
+ })
+ })
+ })
+
+ it('should infer types for onError callback', () => {
+ doNotExecute(() => {
+ mutationOptions({
+ mutationFn: () => {
+ throw new Error('fail')
+ },
+ mutationKey: ['key'],
+ onError: (error) => {
+ expectTypeOf(error).toEqualTypeOf()
+ },
+ })
+ })
+ })
+
+ it('should infer types for variables', () => {
+ doNotExecute(() => {
+ mutationOptions({
+ mutationFn: (vars) => {
+ expectTypeOf(vars).toEqualTypeOf<{ id: string }>()
+ return Promise.resolve(5)
+ },
+ mutationKey: ['with-vars'],
+ })
+ })
+ })
+
+ it('should infer context type correctly', () => {
+ doNotExecute(() => {
+ mutationOptions({
+ mutationFn: () => Promise.resolve(5),
+ mutationKey: ['key'],
+ onMutate: () => {
+ return { name: 'context' }
+ },
+ onSuccess: (_data, _variables, context) => {
+ expectTypeOf(context).toEqualTypeOf<{ name: string } | undefined>()
+ },
+ })
+ })
+ })
+
+ it('should error if mutationFn return type mismatches TData', () => {
+ doNotExecute(() => {
+ mutationOptions({
+ // @ts-expect-error this is a good error, because return type is string, not number
+ mutationFn: async () => Promise.resolve('wrong return'),
+ })
+ })
+ })
+
+ it('should allow mutationKey to be omitted', () => {
+ doNotExecute(() => {
+ mutationOptions({
+ mutationFn: () => Promise.resolve(123),
+ onSuccess: (data) => {
+ expectTypeOf(data).toEqualTypeOf()
+ },
+ })
+ })
+ })
+
+ it('should infer all types when not explicitly provided', () => {
+ doNotExecute(() => {
+ expectTypeOf(
+ mutationOptions({
+ mutationFn: (id: string) => Promise.resolve(id.length),
+ mutationKey: ['key'],
+ onSuccess: (data) => {
+ expectTypeOf(data).toEqualTypeOf()
+ },
+ }),
+ ).toEqualTypeOf<
+ WithRequired<
+ UseMutationOptions,
+ 'mutationKey'
+ >
+ >()
+ expectTypeOf(
+ mutationOptions({
+ mutationFn: (id: string) => Promise.resolve(id.length),
+ onSuccess: (data) => {
+ expectTypeOf(data).toEqualTypeOf()
+ },
+ }),
+ ).toEqualTypeOf<
+ OmitKeyof<
+ UseMutationOptions,
+ 'mutationKey'
+ >
+ >()
+ })
+ })
+
+ it('should infer types when used with useMutation', () => {
+ doNotExecute(() => {
+ const mutation = useMutation(
+ mutationOptions({
+ mutationKey: ['key'],
+ mutationFn: () => Promise.resolve('data'),
+ onSuccess: (data) => {
+ expectTypeOf(data).toEqualTypeOf()
+ },
+ }),
+ )
+ expectTypeOf(mutation).toEqualTypeOf<
+ UseMutationResult
+ >()
+
+ // should allow when used with useMutation without mutationKey
+ useMutation(
+ mutationOptions({
+ mutationFn: () => Promise.resolve('data'),
+ onSuccess: (data) => {
+ expectTypeOf(data).toEqualTypeOf()
+ },
+ }),
+ )
+ })
+ })
+
+ it('should be used with useMutation and spread with additional options', () => {
+ doNotExecute(() => {
+ const result = useMutation({
+ ...mutationOptions({
+ mutationKey,
+ mutationFn,
+ }),
+ retry: 3,
+ })
+
+ expectTypeOf(result).toEqualTypeOf<
+ UseMutationResult<{ field: string }, unknown, { id: string }, unknown>
+ >()
+ })
+ })
+
+ it('should preserve mutationKey for use with useIsMutating/queryClient', () => {
+ doNotExecute(() => {
+ const options = mutationOptions({
+ mutationKey: ['todos', 'create'] as const,
+ mutationFn: (input: { title: string }) =>
+ Promise.resolve({ id: 1, title: input.title }),
+ })
+
+ // mutationKey is MutationKey, usable with filters
+ expectTypeOf(options.mutationKey).toMatchTypeOf()
+ })
+ })
+
+ it('should work with void variables (no arguments to mutationFn)', () => {
+ doNotExecute(() => {
+ const options = mutationOptions({
+ mutationKey,
+ mutationFn: () => Promise.resolve('done'),
+ })
+
+ const result = useMutation(options)
+
+ // mutate should be callable without arguments
+ result.mutate()
+ })
+ })
+
+ it('should infer TContext from onMutate when explicitly typed', () => {
+ doNotExecute(() => {
+ mutationOptions<
+ { success: boolean },
+ unknown,
+ string,
+ { previousData: string }
+ >({
+ mutationKey,
+ mutationFn: (_id: string) => Promise.resolve({ success: true }),
+ onMutate: (variables) => {
+ expectTypeOf(variables).toEqualTypeOf()
+ return { previousData: 'backup' }
+ },
+ onError: (_error, variables, context) => {
+ expectTypeOf(variables).toEqualTypeOf()
+ expectTypeOf(context).toEqualTypeOf<
+ { previousData: string } | undefined
+ >()
+ },
+ onSuccess: (data, variables, context) => {
+ expectTypeOf(data).toEqualTypeOf<{ success: boolean }>()
+ expectTypeOf(variables).toEqualTypeOf()
+ expectTypeOf(context).toEqualTypeOf<
+ { previousData: string } | undefined
+ >()
+ },
+ onSettled: (data, _error, variables, context) => {
+ expectTypeOf(data).toEqualTypeOf<{ success: boolean } | undefined>()
+ expectTypeOf(variables).toEqualTypeOf()
+ expectTypeOf(context).toEqualTypeOf<
+ { previousData: string } | undefined
+ >()
+ },
+ })
+ })
+ })
+
+ it('should work with complex generic types', () => {
+ doNotExecute(() => {
+ interface CreateUserInput {
+ name: string
+ email: string
+ roles: Array<'admin' | 'user'>
+ }
+
+ interface User {
+ id: number
+ name: string
+ email: string
+ roles: Array<'admin' | 'user'>
+ createdAt: Date
+ }
+
+ interface OptimisticContext {
+ previousUsers: Array
+ tempId: number
+ }
+
+ const options = mutationOptions<
+ User,
+ unknown,
+ CreateUserInput,
+ OptimisticContext
+ >({
+ mutationKey: ['users', 'create'] as const,
+ mutationFn: (input: CreateUserInput) =>
+ Promise.resolve({
+ id: 1,
+ ...input,
+ createdAt: new Date(),
+ } as User),
+ onMutate: (variables) => {
+ expectTypeOf(variables).toEqualTypeOf()
+ return { previousUsers: [], tempId: Date.now() }
+ },
+ onError: (_error, _variables, context) => {
+ expectTypeOf(context).toEqualTypeOf()
+ },
+ onSuccess: (data, variables, context) => {
+ expectTypeOf(data).toEqualTypeOf()
+ expectTypeOf(variables).toEqualTypeOf()
+ expectTypeOf(context).toEqualTypeOf()
+ },
+ })
+
+ const result = useMutation(options)
+ expectTypeOf(result.data).toEqualTypeOf()
+ })
+ })
+
+ it('should be usable in a factory pattern', () => {
+ doNotExecute(() => {
+ const mutations = {
+ create: () =>
+ mutationOptions({
+ mutationKey: ['items', 'create'] as const,
+ mutationFn: (input: { name: string }) =>
+ Promise.resolve({ id: 1, name: input.name }),
+ }),
+ delete: () =>
+ mutationOptions({
+ mutationKey: ['items', 'delete'] as const,
+ mutationFn: (_id: number) => Promise.resolve(undefined),
+ }),
+ }
+
+ const createResult = useMutation(mutations.create())
+ expectTypeOf(createResult.data).toEqualTypeOf<
+ { id: number; name: string } | undefined
+ >()
+
+ const deleteResult = useMutation(mutations.delete())
+ expectTypeOf(deleteResult.data).toEqualTypeOf()
+ })
+ })
+
+ it('should work with queryClient mutation cache filters', () => {
+ doNotExecute(async () => {
+ const queryClient = useQueryClient()
+ const options = mutationOptions({
+ mutationKey: ['key'] as const,
+ mutationFn: () => Promise.resolve('data'),
+ })
+
+ queryClient.getMutationCache().findAll({
+ mutationKey: options.mutationKey,
+ })
+ })
+ })
+
+ it('should infer types when used with queryClient.isMutating', () => {
+ doNotExecute(() => {
+ const queryClient = new QueryClient()
+
+ const isMutating = queryClient.isMutating({
+ mutationKey: mutationOptions({
+ mutationKey: ['key'],
+ mutationFn: () => Promise.resolve(5),
+ }).mutationKey,
+ })
+ expectTypeOf(isMutating).toEqualTypeOf()
+ })
+ })
+
+ it('should handle union type variables', () => {
+ doNotExecute(() => {
+ type Action =
+ | { type: 'create'; payload: { name: string } }
+ | { type: 'delete'; payload: { id: number } }
+
+ const options = mutationOptions({
+ mutationKey,
+ mutationFn: (_action: Action) => Promise.resolve('done'),
+ })
+
+ const result = useMutation(options)
+ result.mutate({ type: 'create', payload: { name: 'test' } })
+ result.mutate({ type: 'delete', payload: { id: 1 } })
+ })
+ })
+
+ it('should properly narrow mutationKey presence based on overload', () => {
+ doNotExecute(() => {
+ // With mutationKey: mutationKey is required in the return type
+ const withKey = mutationOptions({
+ mutationKey: ['key'] as const,
+ mutationFn: () => Promise.resolve(1),
+ })
+ expectTypeOf(withKey.mutationKey).toMatchTypeOf()
+
+ // Without mutationKey: mutationKey should not be accessible
+ const withoutKey = mutationOptions({
+ mutationFn: () => Promise.resolve(1),
+ })
+ // @ts-expect-error mutationKey should not exist
+ withoutKey.mutationKey
+ })
+ })
+
+ it('should allow mutationKey to be used as MutationKey', () => {
+ doNotExecute(() => {
+ const options = mutationOptions({
+ mutationKey: ['todos', { status: 'active' }] as const,
+ mutationFn: () => Promise.resolve(true),
+ })
+
+ const key: MutationKey = options.mutationKey
+ expectTypeOf(key).toMatchTypeOf()
+ })
+ })
+
+ it('should infer types when used with useIsMutating via mutationKey filter', () => {
+ doNotExecute(() => {
+ const options = mutationOptions({
+ mutationKey: ['key'] as const,
+ mutationFn: () => Promise.resolve(5),
+ })
+
+ // mutationKey from mutationOptions can be used in MutationFilters
+ const isMutating = useIsMutating({
+ mutationKey: options.mutationKey,
+ })
+ expectTypeOf(isMutating).toEqualTypeOf()
+ })
+ })
+
+ it('should infer types when used with useIsMutating passing mutationKey directly', () => {
+ doNotExecute(() => {
+ const options = mutationOptions({
+ mutationKey: ['key'] as const,
+ mutationFn: () => Promise.resolve(5),
+ })
+
+ // v4 useIsMutating accepts MutationKey as first arg
+ const isMutating = useIsMutating(options.mutationKey)
+ expectTypeOf(isMutating).toEqualTypeOf()
+ })
+ })
+
+ it('should not allow passing mutationOptions without mutationKey to useIsMutating filter', () => {
+ doNotExecute(() => {
+ const options = mutationOptions({
+ mutationFn: () => Promise.resolve(5),
+ })
+
+ // @ts-expect-error mutationKey does not exist on options without mutationKey
+ useIsMutating({ mutationKey: options.mutationKey })
+ })
+ })
+})
diff --git a/packages/react-query/src/index.ts b/packages/react-query/src/index.ts
index d04d91e70f3..37d93f63088 100644
--- a/packages/react-query/src/index.ts
+++ b/packages/react-query/src/index.ts
@@ -43,6 +43,7 @@ export {
} from './QueryErrorResetBoundary'
export { useIsFetching } from './useIsFetching'
export { useIsMutating } from './useIsMutating'
+export { mutationOptions } from './mutationOptions'
export { useMutation } from './useMutation'
export { useInfiniteQuery } from './useInfiniteQuery'
export { useIsRestoring, IsRestoringProvider } from './isRestoring'
diff --git a/packages/react-query/src/mutationOptions.ts b/packages/react-query/src/mutationOptions.ts
new file mode 100644
index 00000000000..67a4e2b4f0f
--- /dev/null
+++ b/packages/react-query/src/mutationOptions.ts
@@ -0,0 +1,34 @@
+import type { OmitKeyof, WithRequired } from '@tanstack/query-core'
+import type { UseMutationOptions } from './types'
+
+export function mutationOptions<
+ TData = unknown,
+ TError = unknown,
+ TVariables = void,
+ TContext = unknown,
+>(
+ options: WithRequired<
+ UseMutationOptions,
+ 'mutationKey'
+ >,
+): WithRequired<
+ UseMutationOptions,
+ 'mutationKey'
+>
+export function mutationOptions<
+ TData = unknown,
+ TError = unknown,
+ TVariables = void,
+ TContext = unknown,
+>(
+ options: OmitKeyof<
+ UseMutationOptions,
+ 'mutationKey'
+ >,
+): OmitKeyof<
+ UseMutationOptions,
+ 'mutationKey'
+>
+export function mutationOptions(options: unknown) {
+ return options
+}