Skip to content

feat(react-query): add mutationOptions #8960

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/framework/react/reference/mutationOptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
id: mutationOptions
title: mutationOptions
---

```tsx
mutationOptions({
mutationFn,
...options,
})
```

**Options**

You can generally pass everything to `mutationOptions` that you can also pass to [`useMutation`](./useMutation.md).
20 changes: 20 additions & 0 deletions docs/framework/react/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,26 @@ const data = queryClient.getQueryData<Group[]>(['groups'])
[//]: # 'TypingQueryOptions'
[//]: # 'Materials'

## Typing Mutation Options

Similarly to `queryOptions`, you can use `mutationOptions` to extract mutation options into a separate function:

```ts
function groupMutationOptions() {
return mutationOptions({
mutationKey: ['groups'],
mutationFn: addGroup,
})
}

useMutation({
...groupMutationOptions()
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['groups'] })
})
useIsMutating(groupMutationOptions())
queryClient.isMutating(groupMutationOptions())
```

## Further Reading

For tips and tricks around type inference, have a look at [React Query and TypeScript](./community/tkdodos-blog.md#6-react-query-and-typescript) from
Expand Down
107 changes: 107 additions & 0 deletions packages/react-query/src/__tests__/mutationOptions.test-d.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { assertType, describe, expectTypeOf, it } from 'vitest'
import { useMutation } from 'src/useMutation'
import { mutationOptions } from '../mutationOptions'
import type { UseMutationOptions, UseMutationResult } from 'src/types'
import type { DefaultError } from '@tanstack/query-core'

describe('mutationOptions', () => {
it('should not allow excess properties', () => {
mutationOptions({
mutationFn: () => Promise.resolve(5),
mutationKey: ['key'],
// @ts-expect-error this is a good error, because onMutates does not exist!
onMutates: 1000,
onSuccess: (data) => {
expectTypeOf(data).toEqualTypeOf<number>()
},
})
})

it('should infer types for callbacks', () => {
mutationOptions({
mutationFn: () => Promise.resolve(5),
mutationKey: ['key'],
onSuccess: (data) => {
expectTypeOf(data).toEqualTypeOf<number>()
},
})
})

it('should infer types for onError callback', () => {
mutationOptions({
mutationFn: () => {
throw new Error('fail')
},
onError: (error) => {
expectTypeOf(error).toEqualTypeOf<DefaultError>()
},
})
})

it('should infer types for variables', () => {
mutationOptions<number, DefaultError, { id: string }>({
mutationFn: (vars) => {
expectTypeOf(vars).toEqualTypeOf<{ id: string }>()
return Promise.resolve(5)
},
mutationKey: ['with-vars'],
})
})

it('should infer context type correctly', () => {
mutationOptions<number, DefaultError, void, { name: string }>({
mutationFn: () => Promise.resolve(5),
onMutate: () => {
return { name: 'context' }
},
onSuccess: (_data, _variables, context) => {
expectTypeOf(context).toEqualTypeOf<{ name: string }>()
},
})
})

it('should error if mutationFn return type mismatches TData', () => {
assertType(
mutationOptions<number>({
// @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', () => {
return mutationOptions({
mutationFn: () => Promise.resolve(123),
onSuccess: (data) => {
expectTypeOf(data).toEqualTypeOf<number>()
},
})
})

it('should infer all types when not explicitly provided', () => {
const mutation = mutationOptions({
mutationFn: (id: string) => Promise.resolve(id.length),
onSuccess: (data) => {
expectTypeOf(data).toEqualTypeOf<number>()
},
})

expectTypeOf(mutation).toMatchTypeOf<
UseMutationOptions<number, DefaultError, string>
>()
})

it('should infer types when used with useMutation', () => {
const mutation = useMutation({
...mutationOptions({
mutationKey: ['key'],
mutationFn: () => Promise.resolve({ field: 'test' }),
}),
onSuccess: (data) =>
expectTypeOf(data).toEqualTypeOf<{ field: string }>(),
})
expectTypeOf(mutation).toMatchTypeOf<
UseMutationResult<{ field: string }, DefaultError, void, unknown>
>()
})
})
14 changes: 14 additions & 0 deletions packages/react-query/src/__tests__/mutationOptions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { mutationOptions } from '../mutationOptions'
import type { UseMutationOptions } from '../types'

describe('mutationOptions', () => {
it('should return the object received as a parameter without any modification.', () => {
const object: UseMutationOptions = {
mutationKey: ['key'],
mutationFn: () => Promise.resolve(5),
} as const

expect(mutationOptions(object)).toStrictEqual(object)
})
})
1 change: 1 addition & 0 deletions packages/react-query/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,6 @@ export {
export { useIsFetching } from './useIsFetching'
export { useIsMutating, useMutationState } from './useMutationState'
export { useMutation } from './useMutation'
export { mutationOptions } from './mutationOptions'
export { useInfiniteQuery } from './useInfiniteQuery'
export { useIsRestoring, IsRestoringProvider } from './IsRestoringProvider'
13 changes: 13 additions & 0 deletions packages/react-query/src/mutationOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { DefaultError } from '@tanstack/query-core'
import type { UseMutationOptions } from './types'

export function mutationOptions<
TData = unknown,
TError = DefaultError,
TVariables = void,
TContext = unknown,
>(
options: UseMutationOptions<TData, TError, TVariables, TContext>,
): UseMutationOptions<TData, TError, TVariables, TContext> {
return options
}
Comment on lines +1 to +13
Copy link
Collaborator

@manudeli manudeli May 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If mutationOptions is intended to be reused across various TanStack React Query interfaces—such as useMutation, useIsMutating, and queryClient.isMutating—then it might make sense to make mutationKey a required field, similar to how queryOptions.queryKey is typed.

Suggested change
import type { DefaultError } from '@tanstack/query-core'
import type { UseMutationOptions } from './types'
export function mutationOptions<
TData = unknown,
TError = DefaultError,
TVariables = void,
TContext = unknown,
>(
options: UseMutationOptions<TData, TError, TVariables, TContext>,
): UseMutationOptions<TData, TError, TVariables, TContext> {
return options
}
import type { WithRequired } from 'node_modules/@tanstack/query-core/build/legacy'
import type { DefaultError } from '@tanstack/query-core'
import type { UseMutationOptions } from './types'
export function mutationOptions<
TData = unknown,
TError = DefaultError,
TVariables = void,
TContext = unknown,
>(
options: WithRequired<
UseMutationOptions<TData, TError, TVariables, TContext>,
'mutationKey'
>,
): WithRequired<
UseMutationOptions<TData, TError, TVariables, TContext>,
'mutationKey'
> {
return options
}

Making mutationKey required could help avoid situations like the following:

// Without mutationKey, it’s unavailable for useIsMutating or queryClient.isMutating
// cannot reliably identify the mutation, which may lead to unintended behavior.
function groupMutationOptions() {
  return mutationOptions({
    mutationFn: addGroup,
  });
}

useMutation({
  ...groupMutationOptions(),
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['groups'] }),
});

useIsMutating(groupMutationOptions())
// This cannot detect the isMutating state from the above hook 
// because groupMutationOptions doesn't include a mutationKey.
// but TypeScript compiler doesn't detect this as error

So in my opinion, mutationOptions's mutationKey should be required field.
Additionally, we can make it as optional field later if we need without BREAKING CHANGE.

So when we first add mutationOptions, it might be beneficial to make mutationKey a required field at first

Copy link
Contributor Author

@Ubinquitous Ubinquitous May 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If mutationOptions is used not only in useMutation but also in other interfaces such as useIsMutating, I think it would be better to make it a required value.

One thing I'm concerned about is that developers who only use mutationOptions for useMutation's options might end up writing unnecessary code.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah we can’t make it required; yes filters won’t work then; it’s one of the reasons why I’m against this helper in the first place 😅

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we make mutationKey required? I know its not really used in useMutation often, but it forces us to write code that would work with any api.
I can think of developers wondering why useIsMutating isn't working when they forgot the key.

Or if we can't do this add a default key the api can fall back to if no key is provided?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tbh I don’t know what the use-case for this helper will be. It’s why I’m so hesitant to add it. I tried to look through issues / discussion to find what people want this for, and the most things I found was:

to do this same sharing/separation for mutations
This will make it easier to create custom hooks.

I'd like this for useMutationState
we want to show a state for mutation in progress on a certain item

I think the first 2 usages would be quite surprised that mutationKey is required, while for the last 2, they would be quite surprised if filter didn’t work as expected. It’s also worth noticing that filters can work without a key - the key is not required in filters.

Since it’s easier to go from required -> optional, I think it’s better to make it required, then see the feedback and loosen it up to optional if there’s lots of negative feedback. @manudeli FYI

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also feel that this is a somewhat controversial interface, so I'm unsure whether it's the right decision to add it immediately. I agree that if we do decide to add mutationOptions, it makes sense to first add mutationOptions with mutationKey as a required field, and then potentially make it optional later based on feedback.

Also, I think it would be good to mark mutationOptions as experimental in the JSDoc

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since it’s easier to go from required -> optional, I think it’s better to make it required, then see the feedback and loosen it up to optional if there’s lots of negative feedback. @manudeli FYI

@TkDodo If we need to provide mutationKey as optional at that time, what if we allowed developers to opt into making it optional by registering it through the Register interface like defaultError? If so, it seems we could offer mutationOptions tailored to library users.

import '@tanstack/react-query'

declare module '@tanstack/react-query' {
  interface Register {
    // This is a tentative name
    mutationOptionsMutationKey: 'optional' // default is 'required'
  }
}

Loading