query icon indicating copy to clipboard operation
query copied to clipboard

useMutationState does not propagate generics into select callback (type inference lost)

Open Pavel-Liteiniy opened this issue 2 months ago • 3 comments

Describe the bug

The useMutationState hook doesn’t propagate generic type parameters (TData, TError, TVariables, TContext) into the select callback. As a result, when filtering by a specific mutationKey, TypeScript doesn’t infer correct types for mutation.state, forcing developers to cast manually.

Your minimal, reproducible example

https://www.typescriptlang.org/play/?#code/FAFwngDgpgBAsgVxAQxASwPYDsDKKQIDOMAvDAORoAmANlOTAD4XRZVpYDmDz5hCAYwFRChHhSgAnSRknkA3MGAcQUgGbJhMAEpROaQqskwA3sAC+S8NBgARKBoQ0QAUWmzSOvQaMwoAD1U2YjMYMJgqB2QnV3dJAC4YDjUpGAAVNxlJRXMYAH50zI9EouylFXVNWER8TFx8KAAeNNtUZE8ELABrLAwAdywAGkK4z3tHZ1LhtIA1ZEk0ZAAjOmIyTp7+ofSAYWxVQI7u3oGAPlNgcJgBfYCQRLS9rAOQJhhOyLUOKCpFK6o2g9Wig3h8HN9fpdwlIsg9Sm8sE4aH9who0DQEJIoHtOvcYIiALZLKQosJojFY3TIQjYOGjZiImjIqFhAwABWihB+iSWGAwdGQWFJMEMqCIiRqqDqeDFhGFADd5osViIHnMFstVqC2OCsD9hfwlgS0CBVFQAIJ4wnEsqWUCQapIKXYADSUDAnipVGwNDA5ukyDAjQ2Jywp0UymelS0kvQ2AA8hA41hCM1ge11scttN4WRxtFJnFpurlVqyPKMNRpk8XkdNmcLlcCU7kwAxLB5RKCsDC5u1V3uzvwFt1N09lkwbCxqBDgAUio1KsIaqVmpEAEpSOc2TJjVzmjW7trPhDzsxHrdDswwV89ZCrtgcIJhKI5wCUEC2sMF6XVekS2uhDDDcUaBA8h6BJuJDbruBhNCGWxnu8WYDMK2ClHOMKyHSWTfquS4rouqzAZeeIXqBrzXjqt4-FBMEYHu8EoWG2r1kKE6PlApp0FQb6Auk6bHrqPzDFhCQjFkCJInhRF-rM+HEdcpHgaRQk0VQdEwDuDFwcGzFIQhqETliICSGAQ7dsKJlmfYNCBhZWDjlceogH0shdHAGCRA5TnhJwAhpGgBIzok1okhOAD6nwFmaQ68vyUCCr2XHID5Bo3NAaUWEokQCHZWIwMshiSJorx5dSxC6ASGCKiqjbhAAxBACyKqowr+YFwWhQgRLhf8IimRgYCzuuiQVtQwrNRgqgCGaIoCAAFj8ThQAA4gII1jZW97hFNM1zQgEDvmtAVBVAs56n062dVA3W9cYVEnneo0wONO1hHtUCzT81wCpI11nRgSCba922TTI+0-UVpmlZOSZ1MgNBVTV50vW9OQ5V9+WwOVojDv2WBpm0dahjmox5lEMRTP+CkiCT2a7KpmZsecdxQMEXjVbVdD1WETUtagUAGg0Eojo+DREygZO4TTslAYzFHhhx8PYMu+POlgibJqmLRfhJsjFrT8vkS8StXFiyDelgvowH2GsAJJUHdNrCiBxWCCAsiziBXycF2jnrgaXFa3UhCzhgKspqLBMh6rkvINLBuy7+xsQSApxo2DE6cFxtspSD3ZqRCwqW1Q8ZLFykjylI4cV1I1fid2mcTcZUBc1A5eVw3tdd1I-tgM370wJ931UHDyaI8j1cg+jE4gegiKo4k2mMXpLPCgEX1IOdP6AYRKcvSvum6ygSt2hUkgaDGYtYK26JGCEE4APQAFQvxOMAv-AqCLbbN8wF0d0fh-ClV9B-F+T8JwBFKnFPkAp2JXFfu-K4n8YD2ywHlBAkQ-4E2IASH+C0OCcBgCAQhxBmo-DQAIQWMA1CdFmnUcBkCrgUPYNQ1Qc47bJmjhrRo3Zhj8MKo5DOW4YDxXgcKJB4C0EYIxNgrhodbYEKISQshOCNYAPdEwicCiBzmR4cmMcki37SLvs4VISwPS6KwCKfARBtFXFFAQQgQ5YzSjsXKbK9obBuPFoLWOKZmi6H4M4Twvj6iC3OGQUIqJ75SBcQYuoZiH5BzoLNThN9EnYE0mkYJMQcgRlytjX6FUYAAEUEBSDADsGgaB2avDMHaIp8xYB0Iwcmd4XJwkylUEEkQMQwk3x6VAU44dI4JPVsmYZASdZ5OcKcYYABHSpZkal1OeEOCpVS1n1Jev6EqQZcn9PmRGN2rw1B8g6F0oZEsJzdIliYCI-FipEIANoAF0YDmGGI8m4kRnYkjzqIZAOdEgvK4F8n5SQnb4h6jaL5pxgCjMeV8cxkg1aPOsWORIrzyAuhcAATXIJ875IooBpLxLOR5TjYDmE0jSr564P4oJZaytlbKn5PxgAAPTyEAA

Steps to reproduce

import { useMutationState } from '@tanstack/react-query'

type MyData = { data: string[] }
type MyError = { code: number; message: string }
type MyVars = { id: number }

const result = useMutationState<
  MutationState<MyData, MyError, MyVars>
>({
  filters: { mutationKey: ['KEY'] },
  select: ({ state }) => state,
})

// Type '({ state }: Mutation<unknown, Error, unknown, unknown>) => MutationState<unknown, Error, unknown, unknown>' is not assignable to type '(mutation: Mutation<unknown, Error, unknown, unknown>) => MutationState<{ data: string[]; }, { code: number; message: string; }, { id: number; }, unknown>'.

Expected behavior

When using filters.mutationKey to target a known mutation, the generic arguments should be propagated to the select callback:

select: (mutation: Mutation<MyData, MyError, MyVars>) => mutation.state

Current workaround - explicitly casting inside select:

select: (mutation) =>
  mutation.state as MutationState<MyData, MyError, MyVars>

How often does this bug happen?

None

Screenshots or Videos

No response

Platform

All

Tanstack Query adapter

react-query

TanStack Query version

v5.59.15

TypeScript version

v5.9.3

Additional context

No response

Pavel-Liteiniy avatar Oct 29 '25 10:10 Pavel-Liteiniy

filters.mutationKey allows for fuzzy matching, so by passing in ['KEY'], you could potentially get multiple, matching queries at runtime.

Also the type parameter we pass to UseMutationState is for the Result type, not the MutationState itself. we would have to add more type parameters to useMutationState, and since Result is the first type param, it means you would have to always define it, which isn’t nice.

What are you trying to achieve with useMutationState exactly?

TkDodo avatar Nov 01 '25 15:11 TkDodo

best I could come up with is this, but still needs a type assertion:

https://www.typescriptlang.org/play/?#code/JYWwDg9gTgLgBAbzgVwM4FMCyyYEMbAQB2AynjOgDRzbmFFwC+cAZlBCHAOQACeRqPAGMA1gHoo6XEJgBaAI7J0UAJ5cAUOpgqw6GioAi+XHAC8iOABNjALjiCowIgHMA2gF0mWnXswqAolDsUGZwgcHeuvoAarhQqKFIwJZ2RMggAEbKXupCxIJwkqjIADbw5mhYOPj0ZPjoABQI6nCswGXKqHZIINUExADS6Cp2rlwD-gCaXJ6MlC326CXoMnYNC60NDSB2tDXEADx+RnjUfuHQZyqx8dTIRCJEEADuRAB8AJRmb3AgAHSCepfXAJAByxFBpRKuAyywOAAU4rgQOgKPEDtpdBAWCgMHt+qRyOg3q4AAzuElcDDLGQzBYfeaMD5AA

TkDodo avatar Nov 01 '25 16:11 TkDodo

I also stumbled into this by following the v5 documentation for optimistic mutations https://tanstack.com/query/v5/docs/framework/react/reference/useMutationState:

import { useMutation, useMutationState } from '@tanstack/react-query'

const mutationKey = ['posts']

// Some mutation that we want to get the state for
const mutation = useMutation({
  mutationKey,
  mutationFn: (newPost) => {
    return axios.post('/posts', newPost)
  },
})

const data = useMutationState({
  // this mutation key needs to match the mutation key of the given mutation (see above)
  filters: { mutationKey },
  select: (mutation) => mutation.state.data,
})

In my case the code looks more like this:

const queryKey = 'preferences' as const;
const mutationKey = 'update-preference' as const;
export const useUpdatePreferences = () => {
    const updatePreferences = usePatchPreferences();
    const client = useQueryClient();
    const mutation = useMutation({
        mutationFn: updatePreferences,
        onSettled: () => client.invalidateQueries({ queryKey: [queryKey] }),
        mutationKey: [mutationKey],
    });
    const variables = useMutationState({
        filters: {
            mutationKey: [mutationKey],
            status: 'pending',
        },
        select: (m) => m.state.variables as UpdatePreference,
    });

    return { ...mutation, variables };
};

where I override variables so that the user can change multiple preferences simultaneously.

Altering your example a bit @TkDodo, would it be possible to do something like this:

import { Mutation, MutationState, MutationFilters, QueryClient } from '@tanstack/react-query'

declare function useMutationState<TResult = MutationState, TSelect = Mutation>(options?: MutationStateOptions<TResult, TSelect>, queryClient?: QueryClient): Array<TResult>;
type MutationStateOptions<TResult = MutationState, TSelect = Mutation> = {
    filters?: MutationFilters;
    select?: (mutation: TSelect) => TResult | undefined;
};

const result = useMutationState<string, Mutation<unknown, Error, string>>({
  filters: { mutationKey: ['KEY'] },
  select: (m) =>  m.state.variables,
});

I am unsure of the 'TResult | undefined' - that comes from TData and TVariables being typed with '| undefined' in the MutationState interface, but the result is still NonNullable. Its a small lie, but I think its always been there, though implicit.

Anyways, the type assertion isn't the end of the world, and I think using variables is a pretty neat way of achieving optimistic updates. If you find the time, let me know what you think. Keep up the good work.

Playground: https://www.typescriptlang.org/play/?#code/JYWwDg9gTgLgBAbzgWQK4wIY2BAdgGhXSx1wGVMYBTQtS0gMWABtqoBnQgRVSqgE8Aws2BVc8AL5wAZlAgg4AcgACmXO0wBjANYB6KFQyaYAWgCOvAYoBQ1gCZVNzDAZmpcx0nFTsqdEngUWFQAPAAqAEpU7KiscAC8RPSBlDRwYWRUzI7wif7YeAB8ABQQYAXqAPwAXEkB5KkA8uWk7OFRMayEGVk5hYQWfEIiYjA1cDxDwqLiAJS1AIJQUBj87dGxMIUA3NYw-GBUdRVB1M0VbZEbcXnEJ6ndmdnGCcekha8I1nA-Mixs7HG+UY-z47F2vzgvmeY1qxRAd1ItR6MNmCQ+V068AAPt5cA5pMBcFQ7LsJLtrJo8Bo4AYsa8fH5ESlgiENFAiQBzWjM3AhdzaXAQADuBDgAFFltBCOyuYUSl8foTWGDakgEclcABpKj8WoAbUUWvFAE1FABdOASfDfKG9YxwkBo+IfOAgAB0GmC7oAbi5gBgAEbZTjWCSzXZAA

erandjo avatar Nov 14 '25 19:11 erandjo