Telerik blogs

Optimistic updates in Vue can help interactions feel instantaneous for improved UI. They don’t improve performance though. Get the details for how and when to use them.

In my previous article on Handling Mutations with TanStack Query for Vue, we covered the basics of mutations, lifecycle callbacks and invalidateQueries. We brushed over optimistic updates with a quick example, but I wanted to come back and give them their own dedicated piece because they really deserve it.

Optimistic updates are one of those features that, when used well, make your app feel instantaneous. Used poorly, though, they can introduce some of the most frustrating bugs you’ll ever try to debug.

Today we’ll walk through the full flow with onMutate, the snapshot and rollback dance, and onSettled, plus we’ll talk honestly about when to reach for them and when to leave them alone.

What Is an Optimistic Update?

The default behavior with mutations goes like this:

  1. The user clicks a button.
  2. The request fires.
  3. You wait for the response.
  4. Then, finally, you update the UI.

For most flows, that’s perfectly fine, especially if you’re showing a loading state on the button itself.

But there are some interactions where waiting feels wrong. A like button is the classic example. The user expects that heart to fill in immediately, not 250ms later when the API responds. The same goes for things like favoriting an item, toggling a setting, marking a todo as done or any inline edit that should feel like the user is editing local state directly.

An optimistic update flips the script. Instead of waiting for the server, we update the cached data right away as if the mutation had already succeeded. The mutation still runs in the background, and if it fails we roll back to where we were. That’s the whole idea in a nutshell.

The reason TanStack Query is so good at this is the cache. We already have one place where the data lives, so optimistically writing into it and then either keeping or rolling back that change is very clean.

The Lifecycle We Care About

Three of the lifecycle callbacks from useMutation carry the whole flow:

  • onMutate: Fires before the mutationFn runs. This is where we cancel any in flight queries that could overwrite our optimistic value, take a snapshot of the current cache and write the new value in.
  • onError: Fires if the mutation rejects. This is where we restore the snapshot we took in onMutate.
  • onSettled: Fires after either success or error. This is where we invalidate the query so the server eventually wins, no matter what happened in between.

Notice that onSuccess doesn’t really play a role here. The whole point is, we already updated the UI in onMutate, so there’s nothing extra to do on success. The cache is already showing the new value, we just want the next refetch to confirm it.

A Like Button, End to End

Let’s build a like button on a post that flips a liked flag and increments a likes counter. Assume we already have a query that fetches the list of posts.

PostList.vue

<script setup>
import { useQuery } from '@tanstack/vue-query'
import LikeButton from './LikeButton.vue'

const fetchPosts = async () => {
  const response = await fetch('https://myapp.com/posts')
  if (!response.ok) {
    throw new Error('Failed to fetch posts')
  }
  return response.json()
}

const { data: posts, isPending } = useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
})
</script>

<template>
  <p v-if="isPending">Loading...</p>
  <ul v-else>
    <li v-for="post in posts" :key="post.id">
      <!-- Render the title and a like button per each post -->
      {{ post.title }}

      <LikeButton :post="post" />
    </li>
  </ul>
</template>

Nothing surprising there. Now for the interesting part, let’s look at the LikeButton component itself.

LikeButton.vue

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

const props = defineProps({
  post: { type: Object, required: true },
})

const queryClient = useQueryClient()

const toggleLike = async (postId) => {
  const response = await fetch(`https://myapp.com/posts/${postId}/like`, {
    method: 'POST',
  })
  if (!response.ok) {
    throw new Error('Failed to toggle like')
  }
  return response.json()
}

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

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

    queryClient.setQueryData(['posts'], (old) =>
      old.map((p) =>
        p.id === postId
          ? { 
            ...p, 
            liked: !p.liked, 
            likes: p.liked ? p.likes - 1 : p.likes + 1 
          }
          : p
      )
    )

    return { previousPosts }
  },
  onError: (err, postId, context) => {
    queryClient.setQueryData(['posts'], context.previousPosts)
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] })
  },
})
</script>

<template>
  <button @click="mutate(post.id)">
    {{ post.liked ? '♥' : '♡' }} {{ post.likes }}
  </button>
</template>

Let’s break it down step by step, because there’s a lot happening.

Step 1: Cancel In-flight Queries

await queryClient.cancelQueries({ queryKey: ['posts'] })

This is the easiest one to skip and the easiest one to regret skipping. If a refetch of ['posts'] is currently happening (say, because the user just tabbed back to the window), it could finish after we write our optimistic value, with stale server data, and stomp all over our update. cancelQueries makes sure we have a clean slate before we touch the cache.

Step 2: Snapshot the Current Cache

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

We grab whatever is currently in the cache for that query. This is our “undo” state. If the mutation fails, this is the value we’ll restore.

Step 3: Write the Optimistic Value

queryClient.setQueryData(['posts'], (old) =>
  old.map((p) =>
    p.id === postId
      ? { 
        ...p, 
        liked: !p.liked, 
        likes: p.liked ? p.likes - 1 : p.likes + 1 
      }
      : p
  )
)

setQueryData accepts either a value or an updater function. Using the function form is much safer here because it gives us the latest cached value directly from the cache, rather than relying on something we read earlier.

⚠️ Important! Notice how we map and spread instead of mutating in place. Returning a new array (and a new object for the updated post) is what triggers Vue’s reactivity through TanStack Query’s reactive layer, and it also keeps the snapshot we took above intact for rollback.

Step 4: Return the Context

return { previousPosts }

Whatever you return from onMutate shows up as the third argument (context) in onError and onSettled. This is the official way to thread the snapshot through the lifecycle. Don’t be tempted to stash it in a regular variable in your <script setup>, it will get the wrong value if multiple mutations are in flight at once.

Step 5: Roll Back on Error

onError: (err, postId, context) => {
  queryClient.setQueryData(['posts'], context.previousPosts)
}

If the mutation fails, we put the snapshot back. The UI flips back to its previous state. From the user’s perspective the like just didn’t happen.

In a production app, though, you may want to use a toast or something similar to alert the user that something went wrong. Otherwise, they will only see the button revert back to its previous state with no extra information.

Step 6: Invalidate on Settled

onSettled: () => {
  queryClient.invalidateQueries({ queryKey: ['posts'] })
}

Win or lose, we invalidate the query. On success, this confirms whatever the server actually saved (which might differ from what we optimistically wrote). On error, this gives us authoritative data to overwrite whatever rollback state we ended up in. Either way the cache ends up in sync with the server.

A Note on cancelQueries and Race Conditions

I want to spend an extra moment on cancelQueries, because every time I’ve seen optimistic updates go subtly wrong in production, it has been because of this.

Imagine the user clicks the like button at the same moment that a background refetch from window focus is in flight. Without cancelQueries, the refetch finishes a few milliseconds after our optimistic write and overwrites our heart icon with the old server data. The user sees their like flicker in and then back out. They click again. Now we have a double-fire problem and a confused user.

Calling cancelQueries aborts that in flight fetch and prevents it from touching the cache. It’s one line that prevents an entire category of bugs.

A Second Example: Inline Edit

The like button is the textbook case, but optimistic updates really shine for inline edits too. Imagine an editable title on a document where the user clicks, types and tabs away, and you don’t want them to wait for the round trip before the new title is visible.

EditableTitle.vue

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

const props = defineProps({
  doc: { type: Object, required: true },
})

const draft = ref(props.doc.title)
watch(() => props.doc.title, (next) => { draft.value = next })

const queryClient = useQueryClient()

const updateTitle = async ({ id, title }) => {
  const response = await fetch(`https://myapp.com/docs/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title }),
  })
  if (!response.ok) {
    throw new Error('Failed to update title')
  }
  return response.json()
}

const { mutate } = useMutation({
  mutationFn: updateTitle,
  onMutate: async ({ id, title }) => {
    await queryClient.cancelQueries({ queryKey: ['doc', id] })
    const previousDoc = queryClient.getQueryData(['doc', id])
    queryClient.setQueryData(['doc', id], (old) => ({ ...old, title }))
    return { previousDoc }
  },
  onError: (err, { id }, context) => {
    queryClient.setQueryData(['doc', id], context.previousDoc)
  },
  onSettled: (data, error, { id }) => {
    queryClient.invalidateQueries({ queryKey: ['doc', id] })
  },
})

const onBlur = () => {
  if (draft.value !== props.doc.title) {
    mutate({ id: props.doc.id, title: draft.value })
  }
}
</script>

<template>
  <input v-model="draft" @blur="onBlur" />
</template>

The shape is identical to the like button: cancel, snapshot, write, invalidate. The only difference is that the variables passed to mutate carry both an id and a title, so we destructure them in each callback.

Notice how the id is part of every callback’s variables. This is what lets us roll back the correct document if multiple inline edits are happening on different documents at the same time. Generic context that isn’t keyed by entity ID is one of the easiest ways to introduce bugs in this pattern.

When to Use Optimistic Updates

I’ll be quite honest with you: I don’t reach for optimistic updates by default. The plain “spinner plus invalidate” flow is fine for the majority of forms, edit screens and CRUD buttons in a typical app. The complexity of optimistic updates only pays off in specific situations.

The cases where they really earn their keep:

  • Toggle interactions: Likes, favorites, follows, bookmarks. Anything with a strong “this should feel local” expectation.
  • High-frequency edits: Inline editing of titles, drag-to-reorder, slider inputs that round-trip per change.
  • Slow networks: Anywhere you know your users may be on flaky mobile data and a 500ms round trip will feel painful.

Cases where I would skip them:

  • Form submissions: A clear loading state on the submit button is honest and easy to reason about.
  • Destructive actions: Deleting something optimistically and then having to “undelete” it on error is a worse experience than waiting half a second.
  • Anything where the server might transform the payload: If the response is going to differ in non-trivial ways from what you optimistically wrote, you’ll spend more time fighting reconciliation than you saved on perceived speed.

Big disclaimer though, optimistic updates are a UX tool, not a performance one. They don’t make your API faster, they just hide latency. If your real problem is that the API is slow, fix the API. 😅

Wrapping Up

Optimistic updates take a bit more wiring than a regular mutation, but the four steps are always the same: cancel related queries, snapshot the cache, write the optimistic value and reconcile on settled. Once you’ve written one, the rest tend to be variations on the same theme.

We covered the lifecycle, walked through a like button end to end, and talked about when to reach for optimistic updates or skip them.

The official optimistic updates guide goes into a few more patterns, including using the cache to drive the optimistic value directly from the variables passed to mutate, which is worth a read once you have the basics down.

Happy mutating!


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.