Mutations and Optimistic Updates

useMutation for writes: invalidating queries after a success, updating the cache optimistically, and rolling back.

Syntaxconst { mutate, mutateAsync, isPending, error, reset } = useMutation({ mutationFn, onMutate, onError, onSettled })

A query reads; a mutation writes. useMutation({ mutationFn }) returns mutate, mutateAsync, isPending, isError, isSuccess, error, variables and reset.

mutate against mutateAsync

mutate(variables) returns nothing and never rejects: failures arrive in the error ref and in the onError callback. mutateAsync(variables) returns a promise that resolves with the result and rejects on failure, which is what you want inside a submit handler that has to do something after the write. They are the same request; only the result handling differs.

After a write

The cache now disagrees with the server. Get the client with useQueryClient() and call queryClient.invalidateQueries({ queryKey: ['todos'] }). Entries under that key are marked stale and the mounted ones refetch. A shorter key matches every longer key beginning with it.

Optimistic updates

To show the change before the server confirms it, use three callbacks in order. In onMutate: await queryClient.cancelQueries(...) so an in-flight refetch cannot overwrite you, snapshot with getQueryData, write the new value with setQueryData, and return the snapshot. That returned value is handed to onError and onSettled in the argument after the variables; use it to put the old data back. onSettled invalidates, so both outcomes end with the server's version.

Skipping cancelQueries is the usual bug: a refetch already in flight answers after your optimistic write and replaces it with data from before the change.

Example

Example · vue
<script setup lang="ts">
import { ref } from 'vue'
import { useMutation, useQueryClient } from '@tanstack/vue-query'

interface Todo {
  id: number
  title: string
}

const title = ref('')
const queryClient = useQueryClient()

const { mutate, isPending, isError, error, reset } = useMutation({
  mutationFn: async (newTitle: string): Promise<Todo> => {
    const res = await fetch('/api/todos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: newTitle }),
    })
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    return (await res.json()) as Todo
  },
  onSuccess: () => {
    title.value = ''
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

function submit(): void {
  if (title.value.trim().length === 0) return
  reset()
  mutate(title.value.trim())
}
</script>

<template>
  <form @submit.prevent="submit">
    <input v-model="title" type="text" placeholder="New todo" />
    <button type="submit" :disabled="isPending">
      {{ isPending ? 'Saving' : 'Add' }}
    </button>
  </form>
  <p v-if="isError">Could not save it: {{ error?.message }}</p>
</template>

<style scoped>
form {
  display: flex;
  gap: 0.5rem;
}
</style>

When to use it

  • Add a row and have the list it belongs to refresh without wiring the refetch by hand.
  • Make a checkbox or a like button respond instantly and correct itself if the server refuses.
  • Navigate to a newly created record using the id the server returned.
  • Disable a submit button for exactly as long as the write is in flight.

More examples

An optimistic toggle with a rollback

The checkbox flips the instant it is clicked, because onMutate has already rewritten the ['todos'] cache entry. If the request fails, onError receives the snapshot onMutate returned as its third argument and writes it back, so the tick returns to where it was and a note appears. onSettled then invalidates either way. Note the checkbox is bound with :checked and @change rather than v-model: the prop is read-only, so the component asks for the change instead of mutating what it was given.

Example · vue
<script setup lang="ts">
import { useMutation, useQueryClient } from '@tanstack/vue-query'

interface Todo {
  id: number
  title: string
  done: boolean
}

const props = defineProps<{ todo: Todo }>()
const queryClient = useQueryClient()

const { mutate, isError } = useMutation({
  mutationFn: async (done: boolean): Promise<void> => {
    const res = await fetch(`/api/todos/${props.todo.id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ done }),
    })
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
  },
  onMutate: async (done: boolean) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] })
    const previous = queryClient.getQueryData<Todo[]>(['todos'])
    queryClient.setQueryData<Todo[]>(['todos'], (old) =>
      (old ?? []).map((t) => (t.id === props.todo.id ? { ...t, done } : t)),
    )
    return { previous }
  },
  onError: (_err, _done, onMutateResult) => {
    if (onMutateResult?.previous) {
      queryClient.setQueryData(['todos'], onMutateResult.previous)
    }
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})
</script>

<template>
  <label>
    <input
      type="checkbox"
      :checked="props.todo.done"
      @change="mutate(!props.todo.done)"
    />
    {{ props.todo.title }}
    <span v-if="isError">not saved</span>
  </label>
</template>

<style scoped>
label {
  display: flex;
  gap: 0.5rem;
}
</style>

mutateAsync when the next step depends on the result

Here the handler needs the created project's id to navigate, and it must not navigate if the write failed. mutateAsync gives a promise for both, so the sequence reads top to bottom in one function and the catch owns the failure. The same code written with mutate would navigate immediately, because mutate returns before the request finishes. Awaiting invalidateQueries before the push means the destination route does not briefly render a stale list.

Example · vue
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMutation, useQueryClient } from '@tanstack/vue-query'

interface Project {
  id: number
  name: string
}

const name = ref('')
const message = ref('')
const router = useRouter()
const queryClient = useQueryClient()

const { mutateAsync, isPending } = useMutation({
  mutationFn: async (projectName: string): Promise<Project> => {
    const res = await fetch('/api/projects', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: projectName }),
    })
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    return (await res.json()) as Project
  },
})

async function create(): Promise<void> {
  message.value = ''
  try {
    const project = await mutateAsync(name.value.trim())
    await queryClient.invalidateQueries({ queryKey: ['projects'] })
    await router.push({ name: 'project', params: { id: project.id } })
  } catch (err) {
    message.value = err instanceof Error ? err.message : 'Could not create it'
  }
}
</script>

<template>
  <form @submit.prevent="create">
    <input v-model="name" type="text" placeholder="Project name" />
    <button type="submit" :disabled="isPending">Create</button>
  </form>
  <p v-if="message">{{ message }}</p>
</template>

<style scoped>
p {
  margin-top: 0.5rem;
}
</style>

Patching one cached entry instead of refetching

When the server returns the saved record, that response is already the fresh data, so writing it into the cache in onSuccess saves the extra GET an invalidation would cause. Note the updater receives the old value as possibly undefined — the entry may not exist yet — which is why every one of these functions starts with old ?? []. Prefer invalidation when a write can affect rows you cannot see from here, such as ordering or server-computed totals, and prefer this when the write touches exactly one known record.

Example · ts
// src/queries/todo-cache.ts
import type { QueryClient } from '@tanstack/vue-query'

export interface Todo {
  id: number
  title: string
  done: boolean
}

export function replaceTodo(client: QueryClient, updated: Todo): void {
  client.setQueryData<Todo[]>(['todos'], (old) =>
    (old ?? []).map((todo) => (todo.id === updated.id ? updated : todo)),
  )
  client.setQueryData<Todo>(['todos', 'detail', updated.id], updated)
}

export function countDone(todos: Todo[]): number {
  return todos.filter((todo) => todo.done).length
}

// countDone([
//   { id: 1, title: 'Write', done: true },
//   { id: 2, title: 'Ship', done: false },
// ])
// Output: 1

Discussion

  • Be the first to comment on this lesson.