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 38 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 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
36a8af1
fix(react-query): add function overload
Ubinquitous Jun 10, 2025
22f5ed2
test(react-query): fix mutation options test code
Ubinquitous Jun 10, 2025
1661565
Update docs/framework/react/typescript.md
TkDodo Jun 21, 2025
d7587ba
Merge branch 'main' into feature/react-query-mutation-options
TkDodo Jun 21, 2025
7ee1f5a
Update docs/framework/react/reference/mutationOptions.md
TkDodo Jun 21, 2025
d682a88
Update docs/framework/react/typescript.md
manudeli Jun 21, 2025
f32a7e0
fix: update mutationOptions type definition to allow optional mutatio…
manudeli Jun 22, 2025
0729167
ci: apply automated fixes
autofix-ci[bot] Jun 22, 2025
8730872
Merge branch 'main' into feature/react-query-mutation-options
manudeli Jun 22, 2025
ac2d73f
Merge branch 'main' into feature/react-query-mutation-options
TkDodo Jun 27, 2025
8929c6a
Merge branch 'main' into feature/react-query-mutation-options
manudeli Jun 28, 2025
4adc529
Merge branch 'main' into feature/react-query-mutation-options
manudeli Jun 28, 2025
98e3662
Merge branch 'main' into feature/react-query-mutation-options
manudeli Jun 29, 2025
2f7eb30
chore: add Nick-Lucas as co-author
manudeli Jun 29, 2025
144dcd5
Merge branch 'main' into feature/react-query-mutation-options
manudeli Jul 1, 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: ['addGroup'],
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
185 changes: 185 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,185 @@
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', () => {
// @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<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>()
},
})
})
Comment on lines +79 to +87
Copy link
Collaborator

Choose a reason for hiding this comment

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

the test says 'should allow mutationKey to be omitted', but the implementation of the test passes a mutationKey, so it does the opposite ...


it('should infer all types when not explicitly provided', () => {
expectTypeOf(
mutationOptions({
mutationFn: (id: string) => Promise.resolve(id.length),
mutationKey: ['key'],
onSuccess: (data) => {
expectTypeOf(data).toEqualTypeOf<number>()
},
}),
).toEqualTypeOf<
WithRequired<
UseMutationOptions<number, DefaultError, string>,
'mutationKey'
>
>()
expectTypeOf(
mutationOptions({
mutationFn: (id: string) => Promise.resolve(id.length),
onSuccess: (data) => {
expectTypeOf(data).toEqualTypeOf<number>()
},
}),
).toEqualTypeOf<
Omit<UseMutationOptions<number, DefaultError, string>, 'mutationKey'>
>()
})

it('should infer types when used with useMutation', () => {
const mutation = useMutation(
mutationOptions({
mutationKey: ['key'],
mutationFn: () => Promise.resolve('data'),
onSuccess: (data) => {
expectTypeOf(data).toEqualTypeOf<string>()
},
}),
)
expectTypeOf(mutation).toEqualTypeOf<
UseMutationResult<string, DefaultError, void, unknown>
>()
})
Comment on lines +116 to +129
Copy link
Collaborator

Choose a reason for hiding this comment

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

we should have a test without mutationKey here too, please (like we have below with useIsMutating and useMutationState)


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

useIsMutating(
// @ts-expect-error filters should have mutationKey
mutationOptions({
mutationFn: () => Promise.resolve(5),
}),
)
})

it('should infer types when used with queryClient.isMutating', () => {
const queryClient = new QueryClient()

const isMutating = queryClient.isMutating(
mutationOptions({
mutationKey: ['key'],
mutationFn: () => Promise.resolve(5),
}),
)
expectTypeOf(isMutating).toEqualTypeOf<number>()

queryClient.isMutating(
// @ts-expect-error filters should have mutationKey
mutationOptions({
mutationFn: () => Promise.resolve(5),
}),
)
})

it('should infer types when used with useMutationState', () => {
const mutationState = useMutationState({
filters: mutationOptions({
mutationKey: ['key'],
mutationFn: () => Promise.resolve(5),
}),
})
expectTypeOf(mutationState).toEqualTypeOf<
Array<MutationState<unknown, Error, unknown, unknown>>
>()

useMutationState({
// @ts-expect-error filters should have mutationKey
filters: mutationOptions({
mutationFn: () => Promise.resolve(5),
}),
})
})
})
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'
Loading
Loading