Telerik blogs

Learn how to handle mutations with TanStack Query in Vue to be able to send data back the server and keep the whole app in sync.

In my previous article on Data Fetching with TanStack Query for Vue, we went through all the things TanStack Query gives us for free when it comes to pulling data from an API: caching, automatic refetching on window focus, stale times, polling and a much cleaner template than the usual ref plus onMounted dance.

But of course, fetching data is only half of the story. At some point you’re going to need to actually send data back to the server. Creating users, updating records, deleting items, triggering actions. That’s where mutations come in!

In this article we’ll take a look at how to handle mutations with TanStack Query in Vue, how to hook into their lifecycle, how to keep the rest of your app in sync after something changes, and a little bit on optimistic updates.

What Is a Mutation?

In the context of TanStack Query, a mutation is any operation that changes server state. POST, PUT, PATCH, DELETE, basically anything that isn’t a GET. Queries fetch data; mutations change it.

The reason TanStack Query treats mutations as their own concept instead of just “another query” is because they behave very differently:

  • Queries run automatically when a component mounts or when their key changes.
  • Mutations are fired manually, usually as a reaction to a user action like submitting a form or clicking a button.
  • Queries are cached. Mutations are not.
  • After a mutation succeeds, other queries might now hold outdated data that we need to refresh.

We’ll address all of these, let’s start with the basics.

Your First Mutation with useMutation

The composable we’re going to use is useMutation, and the API is refreshingly simple. Let’s build a form that creates a new user.

CreateUser.vue

<script setup>
import { ref } from 'vue'
import { useMutation } from '@tanstack/vue-query'

const name = ref('')
const email = ref('')

const createUser = async (payload) => {
  const response = await fetch('https://myapp.com/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  })

  if (!response.ok) {
    throw new Error('Failed to create user')
  }

  return response.json()
}

const { mutate, isPending, isError, error } = useMutation({
  mutationFn: createUser,
})

const onSubmit = () => {
  mutate({ name: name.value, email: email.value })
}
</script>

<template>
  <form @submit.prevent="onSubmit">
    <input v-model="name" placeholder="Name" />
    <input v-model="email" placeholder="Email" />
    <button :disabled="isPending" type="submit">
      {{ isPending ? 'Saving...' : 'Create user' }}
    </button>
    <p v-if="isError">Something went wrong: {{ error.message }}</p>
  </form>
</template>

Let’s break it down. useMutation takes a configuration object with one essential property:

  • mutationFn: The async function that actually performs the mutation. It must return a promise. If the promise rejects, TanStack Query considers the mutation failed.

Notice that unlike useQuery, there’s no mutationKey required here. Mutations can be given a key for advanced scenarios, but for most applications you’ll never need one.

The composable returns several reactive properties, and the ones you’ll end up using the most are:

  • mutate: The function you call to actually trigger the mutation. Any arguments you pass here are forwarded as the first parameter of your mutationFn.
  • isPending: A boolean that is true while the mutation is in flight. This one used to be called isLoading in older versions, so if you come across old examples online, that’s why.
  • isError / isSuccess: Booleans that reflect the final state of the last attempt.
  • error: Whatever error was thrown from the mutationFn if it failed.
  • data: The resolved value from the mutationFn if it succeeded.

I can’t stress enough how much cleaner this ends up being compared to writing your own try/catch plus a loading ref plus an error ref every single time you need a form submission.

mutate vs. mutateAsync

useMutation actually returns two different functions that you can use to trigger the mutation: mutate and mutateAsync. They do the same thing but in different ways.

  • mutate(variables) is fire and forget. It does not return a promise. Errors are swallowed by TanStack Query and surfaced through the reactive isError and error properties. You cannot await it.
  • mutateAsync(variables) returns a promise that you can await. If the mutation fails, the promise rejects, and you’re expected to handle the error yourself with a try/catch.

I normally use mutate most of the time because the reactive state is usually all I need. mutateAsync is really nice when you need to chain multiple operations together or when you’re inside a larger async flow and you want the mutation to behave like any other awaited call.

const { mutateAsync } = useMutation({ mutationFn: createUser })

const onSubmit = async () => {
  try {
    const user = await mutateAsync({ name: name.value })
    router.push(`/users/${user.id}`)
  } catch (err) {
    console.error('Could not create user', err)
  }
}

A word of caution: if you use mutateAsync and forget the try/catch, you’ll get an unhandled promise rejection warning in your console.

Lifecycle Callbacks

Let’s dive into the juicy bits of mutations now.

One of the things that makes useMutation so useful in real applications is the set of lifecycle callbacks it exposes. You can pass them either when you create the mutation or when you call mutate.

const { mutate } = useMutation({
  mutationFn: createUser,
  onSuccess: (data, variables) => {
    // data is what mutationFn returned
    console.log('User created!', data)
  },
  onError: (error, variables) => {
    console.error('Something broke', error)
  },
  onSettled: (data, error, variables) => {
    // Runs no matter what, after onSuccess or onError
  },
})

The three callbacks you’ll use the most are:

  • onSuccess: Fires when the mutation resolves successfully. Perfect for showing a toast, closing a modal, resetting a form or navigating away.
  • onError: Fires when the mutation rejects. Great for surfacing error messages to the user.
  • onSettled: Fires after either onSuccess or onError. Ideal for cleanup logic that needs to run regardless of outcome.

Keep in mind that you can also pass these callbacks directly to the mutate:

mutate(payload, {
  onSuccess: () => {
    showToast('Saved!')
    closeModal()
  },
})

If you define callbacks in both places, both will run. The ones on useMutation fire first, then the ones passed to mutate. Remember this if you end up seeing duplicate toasts or double navigations!

Normally you would use the mutate call callbacks whenever you need a specific call of the mutation to do something additional to what is already defined on the useMutation call.

Invalidating Queries After a Mutation

Here’s where mutations really start to shine, and where most of the real world value of TanStack Query lives.

Picture this scenario. You have a user list page that uses useQuery with the key ['users']. The user clicks a button to create a new user, the mutation fires, the backend responds happily with a 201, and… queue drumroll, the list on the screen still shows the old data. The cache has no idea anything changed.

The fix is to tell TanStack Query that the ['users'] query is now stale and should be refetched. We do that via the query client’s invalidateQueries method.

import { useMutation, useQueryClient } from '@tanstack/vue-query'

const queryClient = useQueryClient()

const { mutate } = useMutation({
  mutationFn: createUser,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['users'] })
  },
})

Any query with a matching key is marked stale and, if it is being observed by a mounted component, refetched immediately. If no component is currently observing it, it will simply be refetched the next time it gets mounted.

Pay close attention to what you set to the queryKey!

invalidateQueries({ queryKey: ['users'] }) will also invalidate ['users', 1], ['users', 2] and so on. This default behavior is usually what you will want for you applications, and you will end up setting up keys with specific words like users that you want to invalidate in groups.

If you only want to invalidate exact matches, you can pass exact: true.

queryClient.invalidateQueries({ queryKey: ['users'], exact: true })

I’ve found that 95% of the mutations I write end up following the exact same shape: do the thing, then invalidate the related list and/or detail queries. Once this clicks for you, you’ll start thinking of your data in terms of which queries each mutation affects.

Optimistic Updates

Sometimes waiting for the server feels sluggish. A user clicks “like” on a post, and the heart icon just sits there for 300ms while the request round trips. The fix for this is optimistic updates: update the UI as if the mutation already succeeded, then reconcile with the server afterward.

TanStack Query has first class support for this through the onMutate callback. The general idea goes like this:

  1. On onMutate, cancel any in flight queries for the related key, grab a snapshot of the current cached data, and write the optimistic value into the cache.
  2. Return the snapshot from onMutate so you can use it later.
  3. On onError, roll back to the snapshot.
  4. On onSettled, invalidate the query to make sure the real server data eventually wins.
const likePost = () => { someApiCall() }
const queryClient = useQueryClient()

const { mutate } = useMutation({
  mutationFn: likePost,
  onMutate: async (postId) => {
    await queryClient.cancelQueries({ queryKey: ['posts'] })

    const previousPosts = queryClient.getQueryData(['posts'])

    // We find the postId and add +1 the likes in the query data
    queryClient.setQueryData(['posts'], (old) =>
      old.map((p) => p.id === postId ? { ...p, likes: p.likes + 1 } : p)
    )

    // Don't forget to retun the modified data!
    return { previousPosts }
  },
  onError: (err, variables, context) => {
    // The `context` here is what we returned in onMutate
    queryClient.setQueryData(['posts'], context.previousPosts)
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] })
  },
})

To be quite honest with you, optimistic updates are one of those features that sound wonderful on paper but that I only reach for when the UX genuinely demands it.

For the vast majority of forms and buttons in a typical CRUD app, the plain old “show a spinner, invalidate on success” flow is perfectly fine and a lot easier to reason about. Reach for optimistic updates when the snappiness actually matters, not by default.

Wrapping Up

Mutations are the missing half of the TanStack Query story, and once you pair them with invalidateQueries, the library really starts to pay for itself. You get a consistent way to handle every form, every button, every “do something on the server” action across your entire app. No more scattered loading refs, no more forgetting to refetch data after an update.

Today we covered the basics of useMutation, the difference between mutate and mutateAsync, the lifecycle callbacks, query invalidation and a quick look at optimistic updates. From here I’d recommend poking around the official mutations guide to see the more advanced patterns like mutation scopes and retry configuration.

Happy mutating!


Vue
About the Author

Marina Mosti

Marina Mosti is a frontend web developer with over 18 years of experience in the field. She enjoys mentoring other women on JavaScript and her favorite framework, Vue, as well as writing articles and tutorials for the community. In her spare time, she enjoys playing bass, drums and video games.

Related Posts

Comments

Comments are disabled in preview mode.