Home / Blog / Optimistic Updates and Pinia Colada
Optimistic Updates and Pinia Colada

Optimistic Updates and Pinia Colada

Daniel Kelly
Daniel Kelly
Updated: March 9th 2026

Optimistic updates are a powerful technique for creating responsive, snappy user interfaces. This guide explains what they are, why you'd use them, and how to implement them effectively with Pinia Colada.

What Are Optimistic Updates?

Optimistic updates are a UI pattern where you update the interface immediately when a user performs an action, rather than waiting for the server to confirm the change. You're "optimistically" assuming the operation will succeed.

The Traditional Approach

Without optimistic updates, the typical flow looks like this:

  1. User clicks "Save"
  2. UI shows a loading spinner
  3. Request is sent to the server
  4. Server processes and responds (could take 100ms to several seconds)
  5. UI updates with the new data
Traditional Approach Flow Diagram

This creates a noticeable delay that can make your app feel sluggish.

The Optimistic Approach

With optimistic updates:

  1. User clicks "Save"
  2. UI updates immediately with the expected result
  3. Request is sent to the server in the background
  4. If successful, data is confirmed (user might not even notice)
  5. If it fails, rollback to the previous state and notify the user
Optimistic Approach Flow Diagram

This creates a much more responsive experience.

Why Use Optimistic Updates?

  • Better perceived performance: The UI feels instant, even on slow networks
  • Improved user experience: Users get immediate feedback for their actions
  • Reduced frustration: No waiting for spinners on simple operations
  • Modern feel: Users expect apps to be responsive like native applications

Implementing Optimistic Updates in Pinia Colada

Pinia Colada provides a clean API for implementing optimistic updates through mutation hooks.

Basic Optimistic Update

Here's a simple example that optimistically updates a product:

const queryCache = useQueryCache();

const { mutate: updateProduct } = useMutation({
  mutation: (product: Product) =>
    $fetch(`/api/products/${product.id}`, {
      method: "PUT",
      body: product,
    }),

  // This runs BEFORE the mutation request is sent
  onMutate(newProduct) {
    // Update the cache immediately with the new data
    queryCache.setQueryData(["product", newProduct.id], newProduct);

    // Cancel any in-flight queries to prevent them from overwriting our optimistic update
    queryCache.cancelQueries({ key: ["product", newProduct.id] });

    // Return context for use in other hooks
    return { newProduct };
  },

  // This runs after the mutation completes (success or error)
  async onSettled(updatedProduct) {
    if (!updatedProduct) return;
    // Invalidate queries to refetch fresh data from the server
    await queryCache.invalidateQueries({
      key: ["product", updatedProduct.id],
    });
  },
});

Key Concepts

1. onMutate Hook

The onMutate hook runs synchronously before the mutation request is sent. This is where you:

  • Update the cache with the expected new value using setQueryData()
  • Cancel any pending queries that might overwrite your optimistic update using cancelQueries()
  • Return any context needed for rollback or other hooks

2. queryCache.setQueryData()

This method directly updates the cached data for a specific query key:

queryCache.setQueryData(["product", productId], newProductData);

Any components using useQuery with that key will immediately reflect the change.

3. queryCache.cancelQueries()

This cancels in-flight queries without triggering a refetch. This is important because:

  • A pending query response could arrive after your optimistic update
  • That stale response would overwrite your optimistic changes
  • Canceling prevents this race condition

4. onSettled Hook

This runs after the mutation completes, whether it succeeded or failed. Use it to:

  • Invalidate affected queries to get the true server state
  • Clean up any temporary state

Handling Rollbacks

What happens if the server request fails? You need to rollback the optimistic update.

That's not difficult, we just need to get the old data in the onMutate hook to pass on to the onError hook.

onMutate(newProduct) {
  const oldProduct = queryCache.getQueryData(["product", newProduct.id]);
  // ...code from before
  return { oldProduct, newProduct };
},
// pass to onError hook 👇
onError(err, productInfo, { oldProduct, newProduct }) {

}

Then inside of onError, we can check if the cache still has our optimistic value and if so, we can rollback to the old value.

onError(err, productInfo, { oldProduct, newProduct }) {
    // Get current cache value
    const productInCache = queryCache.getQueryData(["product", productInfo.id]);

    // Only rollback if cache still has our optimistic value
    // (another mutation could have updated it in the meantime)
    if (newProduct === productInCache) {
      queryCache.setQueryData(["product", productInfo.id], oldProduct);
    }
}

Of course, you should also notify the user that the update failed.

onError(err, productInfo, { oldProduct, newProduct }) {
  alert("Error updating the product");
  // ...code from before
}

Here's the complete example with error handling:

const queryCache = useQueryCache();

const { mutate: updateProduct } = useMutation({
  mutation: (product: Product) =>
    $fetch(`/api/products/${product.id}`, {
      method: "PUT",
      body: product,
    }),

  onMutate(newProduct) {
    // Save the OLD data before updating
    const oldProduct = queryCache.getQueryData(["product", newProduct.id]);

    // Apply the optimistic update
    queryCache.setQueryData(["product", newProduct.id], newProduct);
    queryCache.cancelQueries({ key: ["product", newProduct.id] });

    // Return both old and new for rollback capability
    return { oldProduct, newProduct };
  },

  // Handle errors and rollback
  onError(err, productInfo, { oldProduct, newProduct }) {
    // Notify the user
    alert("Error updating the product");

    // Get current cache value
    const productInCache = queryCache.getQueryData(["product", productInfo.id]);

    // Only rollback if cache still has our optimistic value
    // (another mutation could have updated it in the meantime)
    if (newProduct === productInCache) {
      queryCache.setQueryData(["product", productInfo.id], oldProduct);
    }
  },

  async onSettled(updatedProduct) {
    if (!updatedProduct) return;
    await queryCache.invalidateQueries({
      key: ["product", updatedProduct.id],
    });
  },
});

Why Check Before Rolling Back?

Notice this check before applying the rollback:

if (newProduct === productInCache) {
  queryCache.setQueryData(["product", productInfo.id], oldProduct);
}

This is important because:

  • The user might have made another change while the first request was in flight
  • Another query might have updated the cache
  • Rolling back blindly could overwrite newer, valid data

By checking if the cache still contains our optimistic value, we ensure we only rollback our own changes.

Alternative: Optimistic Updates via the UI

For simpler cases, you can handle optimistic updates directly in the template using the mutation's variables property:

<script setup lang="ts">
  const { data: productList } = useQuery({
    key: ["products"],
    query: () => getProductList(),
  });

  const queryCache = useQueryCache();
  const {
    mutate,
    isLoading,
    variables: newProduct,
  } = useMutation({
    mutation: (name: string) => createProduct(name),
    async onSettled() {
      await queryCache.invalidateQueries({ key: ["products"] });
    },
  });
</script>

<template>
  <ul v-if="productList">
    <li v-for="product in productList" :key="product.id">{{ product.name }}</li>
    <!-- Show the pending item while mutation is loading -->
    <li v-if="isLoading" style="opacity: 0.5">{{ newProduct }}</li>
  </ul>
</template>

This approach is simpler but less powerful than cache-based optimistic updates.

Complete Mutation Hook Lifecycle

Here's how all the mutation hooks work together:

useMutation({
  mutation: async (data) => {
    // The actual API call
  },

  onMutate(variables) {
    // 1. Runs BEFORE the mutation
    // Use for: optimistic updates, saving previous state
    return { previousData }; // Context for other hooks
  },

  onSuccess(data, variables, context) {
    // 2. Runs if mutation SUCCEEDS
    // Use for: updating cache with server response
  },

  onError(error, variables, context) {
    // 3. Runs if mutation FAILS
    // Use for: rollback, error notifications
  },

  onSettled(data, error, variables, context) {
    // 4. Runs ALWAYS after mutation completes
    // Use for: invalidating queries, cleanup
  },
});

Pinia Colada Optimistic Updates Best Practices

1. Always Invalidate After Settling

Even with optimistic updates, invalidate the affected queries after the mutation settles. This ensures your cache eventually reflects the true server state:

async onSettled(data) {
  await queryCache.invalidateQueries({ key: ['relevant-query'] });
}

2. Cancel Related Queries

Always cancel queries that might overwrite your optimistic update:

queryCache.cancelQueries({ key: ["product", id] });

3. Keep Track of What You Changed

Return the old and new values from onMutate so you can properly rollback:

onMutate(newData) {
  const oldData = queryCache.getQueryData(key);
  queryCache.setQueryData(key, newData);
  return { oldData, newData };
}

4. Verify Before Rolling Back

Check that the cache still contains your optimistic value before rolling back:

onError(err, vars, { oldData, newData }) {
  if (newData === queryCache.getQueryData(key)) {
    queryCache.setQueryData(key, oldData);
  }
}

5. Handle Optimistic Update Edge Cases

Consider scenarios like:

  • User making multiple rapid changes
  • Network is slow or unreliable
  • Multiple mutations affecting the same data
  • User navigates away before mutation completes

When NOT to Use Optimistic Updates

Optimistic updates aren't always appropriate:

  • Critical operations: Financial transactions, irreversible actions
  • Complex server-side logic: When the server significantly transforms the data
  • High failure rate operations: When errors are common
  • Operations with side effects: When the server triggers emails, notifications, etc.

In these cases, it's better to show a loading state and wait for server confirmation.

Wrapping Up

Optimistic updates are a powerful technique for creating responsive, snappy user interfaces. They allow you to update the interface immediately when a user performs an action, rather than waiting for the server to confirm the change. This creates a much more responsive experience for the user.

Pinia Colada makes it easy to implement optimistic updates with its useMutation hook. If you want to learn more about Pinia Colada, watch our complete course-- Pinia Colada: Scalable Data Handling in Vue.

Start learning Vue.js for free

Daniel Kelly
Daniel Kelly
Daniel is the lead instructor at Vue School and enjoys helping other developers reach their full potential. He has 10+ years of developer experience using technologies including Vue.js, Nuxt.js, and Laravel.

Comments

Latest Vue School Articles

Using Pretext in Vue to Build Variable-Height UI Without Layout Thrash

Using Pretext in Vue to Build Variable-Height UI Without Layout Thrash

Learn how to use Pretext in Vue to measure multiline text without hidden DOM probes, forced reflow, or brittle getBoundingClientRect loops.
Daniel Kelly
Daniel Kelly
Generating Random IDs in Vue.js

Generating Random IDs in Vue.js

How Vue 3.5’s useId() composable gives you stable, unique DOM IDs for forms and accessibility—without manual counters or hydration bugs.
Daniel Kelly
Daniel Kelly
VueSchool logo

Our goal is to be the number one source of Vue.js knowledge for all skill levels. We offer the knowledge of our industry leaders through awesome video courses for a ridiculously low price.

More than 200.000 users have already joined us. You are welcome too!

Follow us on Social

© All rights reserved. Made with ❤️ by BitterBrains, Inc.