
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.
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.
Without optimistic updates, the typical flow looks like this:

This creates a noticeable delay that can make your app feel sluggish.
With optimistic updates:

This creates a much more responsive experience.
Pinia Colada provides a clean API for implementing optimistic updates through mutation hooks.
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],
});
},
});onMutate HookThe onMutate hook runs synchronously before the mutation request is sent. This is where you:
setQueryData()cancelQueries()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.
queryCache.cancelQueries()This cancels in-flight queries without triggering a refetch. This is important because:
onSettled HookThis runs after the mutation completes, whether it succeeded or failed. Use it to:
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],
});
},
});Notice this check before applying the rollback:
if (newProduct === productInCache) {
queryCache.setQueryData(["product", productInfo.id], oldProduct);
}This is important because:
By checking if the cache still contains our optimistic value, we ensure we only rollback our own changes.
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.
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
},
});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'] });
}Always cancel queries that might overwrite your optimistic update:
queryCache.cancelQueries({ key: ["product", id] });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 };
}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);
}
}Consider scenarios like:
Optimistic updates aren't always appropriate:
In these cases, it's better to show a loading state and wait for server confirmation.
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.



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!
© All rights reserved. Made with ❤️ by BitterBrains, Inc.