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 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
596896d
feat(react-query): add mutationOptions
Ubinquitous Apr 6, 2025
ff15e5d
test(react-query): add DataTag test case
Ubinquitous Apr 7, 2025
ea54b58
Merge branch 'main' into feature/react-query-mutation-options
TkDodo May 1, 2025
2972edd
fix(react-query): remove unnecessary types from mutation
Ubinquitous May 1, 2025
08a5026
fix(react-query): remove unncessary type overload
Ubinquitous May 1, 2025
f3b74c0
Merge branch 'main' into feature/react-query-mutation-options
manudeli May 1, 2025
a4560d3
Merge branch 'main' into feature/react-query-mutation-options
manudeli May 5, 2025
6889638
chore(react-query): add mutationOptions to barrel file
Ubinquitous May 5, 2025
b844dee
Merge branch 'main' into feature/react-query-mutation-options
manudeli May 6, 2025
e61227d
Merge branch 'main' into feature/react-query-mutation-options
manudeli May 6, 2025
33d3e9f
fix(react-query): fix test eslint issue
Ubinquitous May 7, 2025
fd7b9f9
docs(react-query): add more examples
Ubinquitous May 7, 2025
6ee8c76
Merge branch 'main' into feature/react-query-mutation-options
manudeli May 7, 2025
299a19f
Merge branch 'main' into feature/react-query-mutation-options
manudeli May 9, 2025
2622902
Merge branch 'main' into feature/react-query-mutation-options
TkDodo May 13, 2025
9ded37d
test(react-query): add more test cases
Ubinquitous May 20, 2025
48d867b
chore(react-query): Change mutaitonKey to required
Ubinquitous Jun 6, 2025
05f4fc0
fix(react-query): fix test code type error
Ubinquitous Jun 6, 2025
b202d6e
test(react-query): add testcase when used with other mutation util
Ubinquitous Jun 7, 2025
167fb8c
fix(react-query): fix error test code and avoid use deprecateed method
Ubinquitous Jun 7, 2025
2b85c72
fix(react-query): fix error test code and avoid use deprecateed method
Ubinquitous Jun 7, 2025
df3545a
fix(react-query): fix import detect error
Ubinquitous Jun 7, 2025
08769ac
fix(react-query): fix import detect error
Ubinquitous Jun 7, 2025
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'], // mutationKey is required
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
163 changes: 163 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,163 @@
import { assertType, describe, expectTypeOf, it } from 'vitest'
import { QueryClient } from '@tanstack/query-core'
import { useIsMutating, useMutation, useMutationState } from '..'
import { mutationOptions } from '../mutationOptions'
import type {
DefaultError,
MutationState,
WithRequired,
} from '@tanstack/query-core'
import type { UseMutationOptions, UseMutationResult } from '../types'

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 not allow usage without mutationKey', () => {
// @ts-expect-error this is a good error, because mutationKey is required
mutationOptions({
mutationFn: () => Promise.resolve(5),
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')
},
mutationKey: ['key'],
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),
mutationKey: ['key'],
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),
mutationKey: ['key'],
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),
mutationKey: ['key'],
onSuccess: (data) => {
expectTypeOf(data).toEqualTypeOf<number>()
},
})

expectTypeOf(mutation).toEqualTypeOf<
WithRequired<
UseMutationOptions<number, DefaultError, string>,
'mutationKey'
>
>()
})

it('should infer types when used with useMutation', () => {
const mutationOpts = mutationOptions({
mutationKey: ['key'],
mutationFn: () => Promise.resolve('data'),
onSuccess: (data) => {
expectTypeOf(data).toEqualTypeOf<string>()
},
})

const mutation = useMutation(mutationOpts)
expectTypeOf(mutation).toEqualTypeOf<
UseMutationResult<string, DefaultError, void, unknown>
>()
})

it('should infer types when used with useIsMutating', () => {
const mutationOpts = mutationOptions({
mutationKey: ['key'],
mutationFn: () => Promise.resolve(5),
})

const isMutating = useIsMutating(mutationOpts)
expectTypeOf(isMutating).toEqualTypeOf<number>()
})

it('should infer types when used with queryClient.isMutating', () => {
const queryClient = new QueryClient()
const mutationOpts = mutationOptions({
mutationKey: ['key'],
mutationFn: () => Promise.resolve(5),
})

const isMutating = queryClient.isMutating(mutationOpts)
expectTypeOf(isMutating).toEqualTypeOf<number>()
})

it('should infer types when used with useMutationState', () => {
const mutationOpts = mutationOptions({
mutationKey: ['key'],
mutationFn: () => Promise.resolve(5),
})

const mutationState = useMutationState({ filters: mutationOpts })
expectTypeOf(mutationState).toEqualTypeOf<
Array<MutationState<unknown, Error, unknown, unknown>>
>()
})
})
128 changes: 128 additions & 0 deletions packages/react-query/src/__tests__/mutationOptions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, expect, it, vi } from 'vitest'
import { QueryClient } from '@tanstack/query-core'
import { sleep } from '@tanstack/query-test-utils'
import { fireEvent } from '@testing-library/react'
import { mutationOptions } from '../mutationOptions'
import { useIsMutating, useMutation, useMutationState } from '..'
import { renderWithClient } from './utils'
import type { MutationState } from '@tanstack/query-core'

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

expect(mutationOptions(object)).toStrictEqual(object)
})

it('should return the number of fetching mutations when used with useIsMutating', async () => {
const isMutatingArray: Array<number> = []
const queryClient = new QueryClient()

function IsMutating() {
const isMutating = useIsMutating()
isMutatingArray.push(isMutating)
return null
}

const mutationOpts = mutationOptions({
mutationKey: ['key'],
mutationFn: () => sleep(50).then(() => 'data'),
})

function Mutation() {
const { mutate } = useMutation(mutationOpts)

return (
<div>
<button onClick={() => mutate()}>mutate</button>
</div>
)
}

function Page() {
return (
<div>
<IsMutating />
<Mutation />
</div>
)
}

const rendered = renderWithClient(queryClient, <Page />)
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))

await vi.waitFor(() => expect(isMutatingArray[0]).toEqual(0))
await vi.waitFor(() => expect(isMutatingArray[1]).toEqual(1))
await vi.waitFor(() => expect(isMutatingArray[2]).toEqual(0))
await vi.waitFor(() =>
expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0),
)
})

it('should return the number of fetching mutations when used with queryClient.isMutating', async () => {
const isMutatingArray: Array<number> = []
const queryClient = new QueryClient()

const mutationOpts = mutationOptions({
mutationKey: ['mutation'],
mutationFn: () => sleep(500).then(() => 'data'),
})

function Mutation() {
const isMutating = queryClient.isMutating(mutationOpts)
const { mutate } = useMutation(mutationOpts)
isMutatingArray.push(isMutating)

return (
<div>
<button onClick={() => mutate()}>mutate</button>
</div>
)
}

const rendered = renderWithClient(queryClient, <Mutation />)
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))

await vi.waitFor(() => expect(isMutatingArray[0]).toEqual(0))
await vi.waitFor(() => expect(isMutatingArray[1]).toEqual(1))
await vi.waitFor(() => expect(isMutatingArray[2]).toEqual(0))
await vi.waitFor(() =>
expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0),
)
})

it('should return the number of fetching mutations when used with useMutationState', async () => {
const mutationStateArray: Array<
MutationState<unknown, Error, unknown, unknown>
> = []
const queryClient = new QueryClient()

const mutationOpts = mutationOptions({
mutationKey: ['mutation'],
mutationFn: () => Promise.resolve('data'),
})

function Mutation() {
const { mutate } = useMutation(mutationOpts)
const data = useMutationState({
filters: { ...mutationOpts, status: 'success' },
})
mutationStateArray.push(...data)

return (
<div>
<button onClick={() => mutate()}>mutate</button>
</div>
)
}

const rendered = renderWithClient(queryClient, <Mutation />)
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))

await vi.waitFor(() => expect(mutationStateArray.length).toEqual(1))
await vi.waitFor(() => expect(mutationStateArray[0]?.data).toEqual('data'))
})
})
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'
19 changes: 19 additions & 0 deletions packages/react-query/src/mutationOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { DefaultError, WithRequired } 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
}
Loading