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

Conversation

Ubinquitous
Copy link
Contributor

mutationOptions helps extracting mutation options into separate functions.

@github-actions github-actions bot added documentation Improvements or additions to documentation package: react-query labels Apr 6, 2025
@Ubinquitous Ubinquitous force-pushed the feature/react-query-mutation-options branch from 5e14207 to 596896d Compare April 6, 2025 10:23
Copy link

nx-cloud bot commented Apr 6, 2025

View your CI Pipeline Execution ↗ for commit 9ded37d.

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 2m 36s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 35s View ↗

☁️ Nx Cloud last updated this comment at 2025-05-20 08:22:12 UTC

Copy link

pkg-pr-new bot commented Apr 6, 2025

More templates

@tanstack/angular-query-devtools-experimental

npm i https://pkg.pr.new/@tanstack/angular-query-devtools-experimental@8960

@tanstack/angular-query-experimental

npm i https://pkg.pr.new/@tanstack/angular-query-experimental@8960

@tanstack/eslint-plugin-query

npm i https://pkg.pr.new/@tanstack/eslint-plugin-query@8960

@tanstack/query-async-storage-persister

npm i https://pkg.pr.new/@tanstack/query-async-storage-persister@8960

@tanstack/query-broadcast-client-experimental

npm i https://pkg.pr.new/@tanstack/query-broadcast-client-experimental@8960

@tanstack/query-core

npm i https://pkg.pr.new/@tanstack/query-core@8960

@tanstack/query-devtools

npm i https://pkg.pr.new/@tanstack/query-devtools@8960

@tanstack/query-persist-client-core

npm i https://pkg.pr.new/@tanstack/query-persist-client-core@8960

@tanstack/query-sync-storage-persister

npm i https://pkg.pr.new/@tanstack/query-sync-storage-persister@8960

@tanstack/react-query

npm i https://pkg.pr.new/@tanstack/react-query@8960

@tanstack/react-query-devtools

npm i https://pkg.pr.new/@tanstack/react-query-devtools@8960

@tanstack/react-query-next-experimental

npm i https://pkg.pr.new/@tanstack/react-query-next-experimental@8960

@tanstack/react-query-persist-client

npm i https://pkg.pr.new/@tanstack/react-query-persist-client@8960

@tanstack/solid-query

npm i https://pkg.pr.new/@tanstack/solid-query@8960

@tanstack/solid-query-devtools

npm i https://pkg.pr.new/@tanstack/solid-query-devtools@8960

@tanstack/solid-query-persist-client

npm i https://pkg.pr.new/@tanstack/solid-query-persist-client@8960

@tanstack/svelte-query

npm i https://pkg.pr.new/@tanstack/svelte-query@8960

@tanstack/svelte-query-devtools

npm i https://pkg.pr.new/@tanstack/svelte-query-devtools@8960

@tanstack/svelte-query-persist-client

npm i https://pkg.pr.new/@tanstack/svelte-query-persist-client@8960

@tanstack/vue-query

npm i https://pkg.pr.new/@tanstack/vue-query@8960

@tanstack/vue-query-devtools

npm i https://pkg.pr.new/@tanstack/vue-query-devtools@8960

commit: 9ded37d

Copy link
Collaborator

@TkDodo TkDodo left a comment

Choose a reason for hiding this comment

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

sorry but it seems like you’ve copy-pasted queryOptions and replaced the word query with the word mutation :/

your starting point should’ve been useMutation, not queryOptions

Copy link

codecov bot commented May 1, 2025

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 84.09%. Comparing base (c89c6a0) to head (9ded37d).
Report is 7 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##             main    #8960       +/-   ##
===========================================
+ Coverage   44.40%   84.09%   +39.68%     
===========================================
  Files         206       26      -180     
  Lines        8174      371     -7803     
  Branches     1822      109     -1713     
===========================================
- Hits         3630      312     -3318     
+ Misses       4100       50     -4050     
+ Partials      444        9      -435     
Components Coverage Δ
@tanstack/angular-query-devtools-experimental ∅ <ø> (∅)
@tanstack/angular-query-experimental ∅ <ø> (∅)
@tanstack/eslint-plugin-query ∅ <ø> (∅)
@tanstack/query-async-storage-persister ∅ <ø> (∅)
@tanstack/query-broadcast-client-experimental ∅ <ø> (∅)
@tanstack/query-codemods ∅ <ø> (∅)
@tanstack/query-core ∅ <ø> (∅)
@tanstack/query-devtools ∅ <ø> (∅)
@tanstack/query-persist-client-core ∅ <ø> (∅)
@tanstack/query-sync-storage-persister ∅ <ø> (∅)
@tanstack/query-test-utils ∅ <ø> (∅)
@tanstack/react-query 95.37% <100.00%> (+0.03%) ⬆️
@tanstack/react-query-devtools 10.00% <ø> (ø)
@tanstack/react-query-next-experimental ∅ <ø> (∅)
@tanstack/react-query-persist-client 100.00% <ø> (ø)
@tanstack/solid-query ∅ <ø> (∅)
@tanstack/solid-query-devtools ∅ <ø> (∅)
@tanstack/solid-query-persist-client ∅ <ø> (∅)
@tanstack/svelte-query ∅ <ø> (∅)
@tanstack/svelte-query-devtools ∅ <ø> (∅)
@tanstack/svelte-query-persist-client ∅ <ø> (∅)
@tanstack/vue-query ∅ <ø> (∅)
@tanstack/vue-query-devtools ∅ <ø> (∅)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Ubinquitous
Copy link
Contributor Author

Thank you for reviewing my PR. I thought queryOptions and mutationOptions could be structured similarly since it was an options-related function. I re-created useMutation as start. I changed it to only have UseMutationOptions, excluding unnecessary data tags and initialData.

@manudeli
Copy link
Collaborator

manudeli commented May 1, 2025

I merged #9094 to resolve below ci failure because of flaky timer tests

image

Comment on lines 247 to 257
function useGroupPostMutation() {
const queryClient = useQueryClient()

return mutationOptions({
mutationKey: ['groups'],
mutationFn: executeGroups,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

It might be worth highlighting in the documentation that mutationOptions can be reused across different interfaces—such as useMutation, useIsMutating, and queryClient.isMutating.

Suggested change
function useGroupPostMutation() {
const queryClient = useQueryClient()
return mutationOptions({
mutationKey: ['groups'],
mutationFn: executeGroups,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
}
function groupMutationOptions() {
return mutationOptions({
mutationKey: ['groups'],
mutationFn: addGroup,
})
}
useMutation({
...groupMutationOptions()
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['groups'] })
})
useIsMutating(groupMutationOptions())
queryClient.isMutating(groupMutationOptions())

Copy link
Collaborator

@manudeli manudeli left a comment

Choose a reason for hiding this comment

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

@TkDodo, @Ubinquitous I'd like to hear your thoughts on my review

Comment on lines +1 to +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
}
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

@manudeli manudeli left a comment

Choose a reason for hiding this comment

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

mutationOptions is needed to be exported from src/index.ts

@manudeli
Copy link
Collaborator

manudeli commented May 6, 2025

@Ubinquitous Resolve eslint error please

@Ubinquitous
Copy link
Contributor Author

Sorry, I fix error that 'should not allow excess properties' test don't have assertions

@andredewaard
Copy link
Contributor

Really looking forward to this PR!

Its really helpful to add reusable optimistic updates, rollbacks etc callbacks for mutations.
But what if we want to use these reusable callbacks but also want a specific side effect in one component on onSucces, like on login I want to redirect.
Is there a way to merge these callbacks somehow?

@Ubinquitous
Copy link
Contributor Author

Ubinquitous commented May 13, 2025

Thank you, @andredewaard.

I'm not sure if I understood it correctly, but it seems like this can basically be implemented using the spread operator. I’ve used a similar pattern when working with queryOptions as well. Is this what you were referring to?

const options = mutationOptions({
  ...
})

useMutation({
  ...options,
  onSuccess: () => {},
})

If you're only changing the funnel in a specific component, I recommend wrapping it in a function for better management. I used to do it that way as well when working with queryOptions.

Alternatively, if you're using a function, you could also add logic to branch only when a specific component name is passed in.

export const postQueries = {
  all: () => ['post-list'],
  list: (): UseQueryOptions<Post[], Error, string> =>
    queryOptions({
      queryKey: [...postListQueries.all()],
      queryFn: () => getPostList(),
      select: (data) => getPostListString(data),
    }),
  detail: (type: PostType): UseSuspenseQueryOptions<PostInfo, Error> =>
    queryOptions({
      queryKey: [...postListQueries.all(), 'detail', type],
      queryFn: () => getPostDetail({ type }),
      select: (data) => data,
    }),
  ...
};

@andredewaard
Copy link
Contributor

@Ubinquitous Thanks for your reply.
I meant to not override the logic happening in onSuccess if I add an onSuccess callback in the component.

So more like this.

useMutation({
  ...options,
  onSuccess: () => {
    options.onSuccess()
    // added logic
  },
})

@TkDodo
Copy link
Collaborator

TkDodo commented May 13, 2025

yes, with optional chaining on the options callback:

useMutation({
  ...options,
  onSuccess: () => {
    options?.onSuccess()
    // added logic
  },
})

@Ubinquitous
Copy link
Contributor Author

Ubinquitous commented May 13, 2025

To override queryOptions without using the spread operator twice, you can use a prop getter.

const compose = (...functions) => (...args) =>
	functions.forEach((fn) => fn?.(...args))

const options = queryOptions({ ... })

const getOptions = ({ onSuccess }) => {
  return {
    onSuccess: compose(onSuccess, options.onSuccess),
    ...options
  }
}

getOptions({
  onSuccess: () => {} // behaves the same
})

Comment on lines 1 to 26
import { describe, expectTypeOf, it } from 'vitest'
import { mutationOptions } from '../mutationOptions'

describe('mutationOptions', () => {
it('should not allow excess properties', () => {
return 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', () => {
return mutationOptions({
mutationFn: () => Promise.resolve(5),
mutationKey: ['key'],
onSuccess: (data) => {
expectTypeOf(data).toEqualTypeOf<number>()
},
})
})
})
Copy link
Collaborator

Choose a reason for hiding this comment

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

In my opinion, we should add more test cases before merging this Pull Request—assuming TkDodo agrees with the proposed interface. I'm not sure whether TkDodo will agree with this interface.

const Example = () => {
  const mutation = useMutation({
    ...mutationOptions({
      mutationKey: ['key'],
      mutationFn: () => Promise.resolve({ field: 'test' })
    }),
    onSuccess: (data) => expectTypeOf(data).toEqualTypeOf<{ field: string }>()
  })
  expectTypeOf(mutation).toEqualTypeOf ...
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks, I added more test cases along with the ones that work with useMutation.

@Ubinquitous
Copy link
Contributor Author

I find mutationOptions useful. Currently, I am using a style that manages queryKeys and mutationKeys by bundling them into javascript objects, and mutationOptions can reduce developers' type mistakes and boilerplate code when using it this way. In addition, it can be used with useMutation, useIsMutating, and queryClient.isMutating.

Just like queryOptions allows call invalidateQueries and use that variable, wouldn't mutationOptions do the same (at useMutation, useIsMutating), keeping the code clean?

export const QUERY_KEY = (uniqueKey) => {
  return {
    get: [...queries.all(), 'get-key', uniqueKey],
    list: [...queries.all(), 'list-key', uniqueKey],
  };
};

export const queries = {
  all: () => ['query-key'],
  get: ({ some }) =>
    queryOptions({
      queryKey: QUERY_KEY(some).get,
      queryFn: () => ...,
    }),
  list: ({ some }) =>
    queryOptions({
      queryKey: QUERY_KEY(some).list,
      queryFn: () => ...,
    }),
  // this
  update: (): UseMutationOptions<
    null,
    Error,
    Request
  > => ({
    mutationFn: (req) => ...,
  }),
};

@TkDodo
Copy link
Collaborator

TkDodo commented May 29, 2025

and mutationOptions can reduce developers' type mistakes and boilerplate code when using it this way

you can achieve the same thing (avoiding type mistakes) by using satisfies UseMutationOptions

it can be used with useMutation, useIsMutating, and queryClient.isMutating.

it will only work with things like isMutating if we make the MutationKey required. It’s not required for useMutation though. That’s the big difference to queries.#

I fear that if we make this helper, it should have mutationKey required, or it won’t do anything with functions that accept mutationFilters.

But if we do that, a lot of people will ask why all a sudden they have to give every mutation a MutationKey when only using it for useMutation ...

@TkDodo
Copy link
Collaborator

TkDodo commented May 29, 2025

you can achieve the same thing (avoiding type mistakes) by using satisfies UseMutationOptions

okay, it actually needs to be satisfies UseMutationOptions<any, any, any, any>, and that won’t work for inference that well:

https://www.typescriptlang.org/play/?ssl=6&ssc=3&pln=6&pc=51#code/JYWwDg9gTgLgBAbwKoGcCmBZArjAhjYCAOwHkwDiUAaOLdbPCogXzgDMoIQ4ByAATxEUeAMYBrAPRQ0uETAC0ARyxooATx4BYAFChIsOLgAehaojgw1YNHACCJiCgCiUTlDisOXXsdNbtOiKU8BDkhEJwALyIOnAgOPjhAGJEAFyGKGpEInAAFEbpRFggAEaqAJRRAHxwAMowUMBEAOb55TqsKIkobMBoKHComAlMZEwoADy4RGo007OGM3MzVTqBweYAJvi4HlG09CPhuaHj7doSEnDXNwB6APxAA

guess we need that helper after all

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
documentation Improvements or additions to documentation package: react-query
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants