skip to content

In TanStack Query v5, how can a component show a pending write optimistically from useMutation's variables without touching the query cache?

level: middleimportance: should knowfreq 42%

answer

  1. the input is kept on the result
  2. render it while pending
  3. variables survive an error
  4. a key to find it elsewhere

basics

~20 s

While isPending is true, render mutation.variables as a provisional item next to the query's data. Keep the mutation pending until the refetch finishes by returning invalidateQueries from onSettled. On error, variables remain, so show a retry.

solid answer

~40 s

`useMutation` keeps the argument of the latest call in `variables`. While `isPending` is true, render it as a provisional entry - for a new comment, a greyed-out row after the fetched comments. Return `invalidateQueries` from `onSettled` so the mutation stays pending until the refreshed list includes the real comment; the provisional row is removed only after the real one has arrived, so the comment never vanishes in between. On failure `variables` are not cleared, so the row can switch to an error style with a retry button that calls `mutate(variables)`. Nothing is written to the cache, so there is nothing to roll back. If the list is rendered by another component, give the mutation a `mutationKey` and read pending variables there with `useMutationState`.

go deeper

for a junior

Recall that useMutation exposes variables and isPending, and that rendering variables while pending shows the write before the server answers.

for a middle

Explain why onSettled returns the invalidation promise, why variables persist after an error, and how useMutationState reads pending mutations elsewhere.

for a senior

Choose between rendering variables and cache writes per interaction, and handle concurrent pending mutations with submittedAt as a key.

for a principal

Prefer the variables pattern as the team default where it suffices, since it removes rollback code and the class of bugs that comes with it.

## The lighter optimistic pattern Optimistic UI in TanStack Query v5 does not require writing to the query cache. The mutation itself keeps its input: **`variables`** on the `useMutation` result holds what was passed to the latest `mutate` call, from the moment it starts. Rendering that input while the request is pending gives the user instant feedback, and there is no cache state to undo. ## Example: posting a comment ```tsx import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' function Comments({ postId }: { postId: string }) { const queryClient = useQueryClient() const comments = useQuery({ queryKey: ['comments', postId], queryFn: () => fetchComments(postId), }) const addComment = useMutation({ mutationFn: (text: string) => postComment(postId, text), onSettled: () => queryClient.invalidateQueries({ queryKey: ['comments', postId] }), }) return ( <ul> {comments.data?.map((c) => <li key={c.id}>{c.text}</li>)} {addComment.isPending && ( <li style={{ opacity: 0.5 }}>{addComment.variables}</li> )} {addComment.isError && ( <li> {addComment.variables} - not sent.{' '} <button onClick={() => addComment.mutate(addComment.variables)}> Retry </button> </li> )} </ul> ) } ``` Three details make it work: 1. **`isPending` gates the provisional row.** It appears as soon as `mutate` is called. 2. **`onSettled` returns the invalidation promise.** Hook-level callbacks are awaited, so the mutation stays pending until the comments query has refetched. The provisional row is removed only after the real comment has arrived - no flicker of "gone, then back". 3. **`variables` survive an error.** They are not cleared when the mutation fails, so the failed text can stay on screen with a retry. ## Across components: `mutationKey` and `useMutationState` When the list and the form are in different components, the list cannot read the form's `useMutation` result. v5 offers **`useMutationState`**, which reads mutations from the `MutationCache` by filter: ```tsx import { useMutationState } from '@tanstack/react-query' const pendingTexts = useMutationState({ filters: { mutationKey: ['addComment', postId], status: 'pending' }, select: (mutation) => mutation.state.variables as string, }) ``` - It returns an **array**, because several matching mutations can be pending at once - each click creates its own mutation. - `mutation.state.submittedAt` is a convenient unique React key for each provisional row. - It needs a **`mutationKey`** on the `useMutation` call to single these mutations out; filtering by `status` alone would match every pending mutation in the app. ## Mutation keys are not query keys The `mutationKey` is easy to confuse with a query key, but it plays a different role: | | query key | mutation key | |---|---|---| | required | yes | no | | identifies a cache entry | yes - one entry per key | no - every `mutate` call creates a new mutation | | deduplicates work | yes, concurrent reads share a fetch | no, two calls send two requests | | used for | caching, matching, invalidation | `useMutationState`, `queryClient.isMutating`, `setMutationDefaults` | ## Limits of the pattern - **Only the latest call.** The `useMutation` result follows its most recent `mutate` call, so its `variables` show one pending item. For several rapid sends, render the array from `useMutationState` instead. - **Input, not output.** `variables` hold what the user typed, not what the server will return: there is no id, author or timestamp yet, which is one more reason to style the row as provisional. - **`reset()` clears it.** Calling `reset()` on the mutation returns it to `idle` and drops `variables`, which is the way to dismiss a failed row. ## Variables versus cache writes | | render `variables` | write the cache in `onMutate` | |---|---|---| | code needed | a conditional row | cancel, snapshot, write, rollback | | rollback | none - nothing was written | required on error | | visible in | components that read the mutation | every component that reads the query | | failure display | natural - `variables` persist | must be built separately | The v5 docs frame the choice the same way: when the optimistic result appears in one place, rendering `variables` is less code and easier to reason about; when several parts of the screen must reflect it, updating the cache does that automatically.

  • Why does useMutationState return an array of variables rather than a single value?
    Each `mutate` call creates a separate mutation in the `MutationCache`, and several with the same `mutationKey` can be pending at once - for example two comments sent quickly. The filter matches all of them, so the hook returns one selected value per mutation.
  • What goes wrong if onSettled calls invalidateQueries without returning the promise?
    The mutation stops being pending as soon as the request finishes, so the provisional row disappears before the refetch brings the real comment. The user sees the comment vanish briefly and then reappear.

saying these in an interview costs you the question

  • Believes optimistic UI in TanStack Query always requires setQueryData and a rollback.
  • Thinks variables are cleared when the mutation fails.
  • Filters useMutationState by status alone and renders every pending mutation in the app.
  • Assumes a mutationKey caches or deduplicates mutations like a query key.