Imagine an online store. This store has thousands of products, and customers are constantly viewing products, adding them to cart, and making purchases. Pinia Colada is an amazing tool for managing this store's data!
Pinia Colada is a smart data fetching layer developed for Vue.js. The name might remind you of a cocktail, but it's actually a tool built on top of the Pinia state management library that makes data fetching operations much easier.
Let's tell a little story to understand this:
Pinia is like a warehouse - Your store's warehouse. It's where you store your products, customer information, and order records. But in this warehouse, you only do storage.
Pinia Colada is like a smart shipping system - It automatically organizes products coming to your warehouse, quickly fetches them when customers request, fetches the same product only once when multiple people want it (this is called deduplication), and most importantly, keeps frequently requested products ready in advance (this is called caching).
<template>
<div class="smart-product-hover">
<h3>π― Smart Product Cards with Cache Optimization</h3>
<div class="product-grid">
<div
v-for="product in allProducts.data"
:key="product.id"
class="product-card"
@mouseenter="handleMouseEnter(product.id)"
@click="navigateToProduct(product.id)"
>
<img :src="product.image" :alt="product.name" />
<h4>{{ product.name }}</h4>
<p class="price">${{ product.price }}</p>
<!-- Show cache status -->
<div class="cache-indicator">
<span v-if="isInCache(product.id)" class="cached">
β‘ Cached
</span>
<span v-else class="not-cached">
π‘ Will load
</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useQueryCache } from '@pinia/colada'
import { useRouter } from 'vue-router'
import { useProducts } from '@/composables/useProducts'
const router = useRouter()
const queryCache = useQueryCache()
const { allProducts, preloadProductFromList, loadProductInBackground } = useProducts()
function handleMouseEnter(productId) {
// Strategy 1: If we have basic info, put it in cache immediately
preloadProductFromList(productId)
// Strategy 2: Start background loading for full details
loadProductInBackground(productId)
}
function isInCache(productId) {
return !!queryCache.getQueryData(['product-details', productId])
}
function navigateToProduct(productId) {
router.push(`/product/${productId}`)
// User will see instant loading because data is already cached!
}
</script>
// Advanced cache management composable
export function useCacheManager() {
const queryCache = useQueryCache()
// Get all cached data
function getAllCachedData() {
// Note: This is conceptual - actual implementation may vary
return queryCache.getQueriesData()
}
// Clear specific cache patterns
function clearProductCaches() {
queryCache.removeQueries({
predicate: (query) => query.queryKey[0] === 'products'
})
}
// Warm up cache with essential data
async function warmupCache() {
try {
// Load essential data into cache
const products = await fetchAllProducts()
queryCache.setQueryData(['products'], products)
// Pre-cache first few product details
const firstFiveProducts = products.slice(0, 5)
await Promise.all(
firstFiveProducts.map(async (product) => {
const details = await fetchProductDetails(product.id)
queryCache.setQueryData(['product-details', product.id], details)
})
)
console.log('β
Cache warmed up successfully')
} catch (error) {
console.error('β Cache warmup failed:', error)
}
}
return {
getAllCachedData,
clearProductCaches,
warmupCache
}
}javascript
// Old way with Pinia - you need to do everything yourself
const useProductStore = defineStore('products', {
state: () => ({
products: [],
loading: false,
error: null
}),
actions: {
async fetchProducts() {
this.loading = true
try {
const response = await fetch('/api/products')
this.products = await response.json()
} catch (error) {
this.error = error
} finally {
this.loading = false
}
}
}
})
// New way with Pinia Colada - everything is automatic!
const { data: products, isPending, error } = useQuery({
key: ['products'],
query: () => fetch('/api/products').then(res => res.json())
})
See? With Pinia Colada, we can accomplish the same task with much less code!
In our online store, we face these problems:
-
Customers view the same product multiple times - Instead of fetching from the server each time, wouldn't it be better to fetch once and store it?
-
Showing loading text is boring - Writing loading and error states everywhere is tedious.
-
It should appear immediately when I add to cart - Instead of waiting for server confirmation, if it appears in the cart, the customer would be happy (optimistic update).
-
Don't lose everything when the page refreshes - With caching, data can be stored for a while.
Pinia Colada solves all these problems!
Let's open our terminal and write these commands:
# If using npm
npm install pinia @pinia/colada
# If using yarn
yarn add pinia @pinia/colada
Let's open our main.js
file and introduce Pinia Colada to our project:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { PiniaColada } from '@pinia/colada'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
// First install Pinia (prepare our warehouse)
app.use(pinia)
// Then add Pinia Colada (set up our smart shipping system)
app.use(PiniaColada, {
// Optional settings
queryOptions: {
staleTime: 30_000, // Data stays fresh for 30 seconds
}
})
app.mount('#app')
Now let's build a real e-commerce application. First, let's list our products.
Let's create an api/products.js
file:
const API_URL = 'https://api.mystore.com'
// Fetch all products
export async function fetchAllProducts() {
const response = await fetch(`${API_URL}/products`)
if (!response.ok) throw new Error('Products could not be loaded!')
return response.json()
}
// Fetch single product details
export async function fetchProductDetails(productId) {
const response = await fetch(`${API_URL}/products/${productId}`)
if (!response.ok) throw new Error('Product not found!')
return response.json()
}
// Fetch products by category
export async function fetchProductsByCategory(category) {
const response = await fetch(`${API_URL}/products?category=${category}`)
if (!response.ok) throw new Error('Category products could not be loaded!')
return response.json()
}
Let's create a ProductList.vue
component:
<template>
<div class="product-list">
<h1>ποΈ Welcome to Our Online Store!</h1>
<!-- Loading state -->
<div v-if="productsLoading && !productList.data" class="loading">
<div class="spinner">π</div>
<p>Products are being prepared...</p>
</div>
<!-- Error state -->
<div v-else-if="productList.error" class="error">
<p>π’ Something went wrong: {{ productList.error.message }}</p>
<button @click="refresh()">π Try Again</button>
</div>
<!-- Success state - Products -->
<div v-else-if="productList.data" class="products">
<!-- Update indicator -->
<div v-if="productsLoading" class="updating">
β¨ Checking for new products...
</div>
<div class="product-grid">
<div
v-for="product in productList.data"
:key="product.id"
class="product-card"
@mouseenter="preloadProduct(product.id)"
>
<img :src="product.image" :alt="product.name" />
<h3>{{ product.name }}</h3>
<p class="price">${{ product.price }}</p>
<router-link :to="`/product/${product.id}`" class="details-button">
ποΈ View Details
</router-link>
</div>
</div>
<button @click="refresh()" class="refresh-button">
π Refresh List
</button>
</div>
</div>
</template>
<script setup>
import { useQuery, useQueryCache } from '@pinia/colada'
import { fetchAllProducts, fetchProductDetails } from '@/api/products'
// Use query cache (for cache management)
const queryCache = useQueryCache()
// Product list query
const {
state: productList, // Data state
asyncStatus: productsLoading, // Is it loading?
refresh, // Refresh function
} = useQuery({
key: ['product-list'], // Unique key
query: fetchAllProducts, // Data fetching function
staleTime: 5 * 60 * 1000, // Fresh for 5 minutes
})
// Preload product details on mouse hover
function preloadProduct(productId) {
// Check if data is already in cache
const cachedData = queryCache.getQueryData(['product-details', productId])
if (!cachedData) {
// If not in cache, trigger a background query
// This will start loading the data in the background
const { state } = useQuery({
key: ['product-details', productId],
query: () => fetchProductDetails(productId),
enabled: true // Force enable even if component doesn't need it yet
})
}
}
</script>
<style scoped>
.product-list {
padding: 20px;
}
.loading {
text-align: center;
padding: 50px;
}
.spinner {
font-size: 48px;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
margin: 20px 0;
}
.product-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
transition: transform 0.2s;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.price {
font-size: 24px;
color: #27ae60;
font-weight: bold;
}
</style>
Now let's show details of a single product and add the "add to cart" feature:
<template>
<div class="product-details">
<!-- Loading -->
<div v-if="loading === 'loading'" class="loading">
<p>π Fetching product details...</p>
</div>
<!-- Error -->
<div v-else-if="product.error" class="error">
<p>π Product not found: {{ product.error.message }}</p>
<router-link to="/products">β¬
οΈ Back to Product List</router-link>
</div>
<!-- Product Details -->
<div v-else-if="product.data" class="product-info">
<div class="product-image">
<img :src="product.data.image" :alt="product.data.name" />
</div>
<div class="product-details-content">
<h1>{{ product.data.name }}</h1>
<p class="description">{{ product.data.description }}</p>
<p class="price">π° ${{ product.data.price }}</p>
<p class="stock">π¦ Stock: {{ product.data.stock }} units</p>
<!-- Add to Cart Section -->
<div class="add-to-cart-section">
<label>Quantity:</label>
<input
v-model.number="quantity"
type="number"
min="1"
:max="product.data.stock"
/>
<button
@click="addToCart()"
:disabled="cartMutation.asyncStatus === 'loading' || product.data.stock === 0"
class="add-to-cart-button"
>
<span v-if="cartMutation.asyncStatus === 'loading'">
β³ Adding...
</span>
<span v-else>
π Add to Cart
</span>
</button>
</div>
<!-- Success Message -->
<div v-if="added" class="success-message">
β
Product added to cart!
</div>
<!-- Error Message -->
<div v-if="cartMutation.state.error" class="error-message">
β {{ cartMutation.state.error.message }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
import { fetchProductDetails } from '@/api/products'
import { addProductToCart } from '@/api/cart'
const route = useRoute()
const queryCache = useQueryCache()
const quantity = ref(1)
const added = ref(false)
// Product details query
const {
state: product,
asyncStatus: loading
} = useQuery({
key: () => ['product-details', route.params.id],
query: () => fetchProductDetails(route.params.id),
enabled: () => !!route.params.id // Run if ID exists
})
// Add to cart mutation
const cartMutation = useMutation({
mutation: ({ productId, quantity }) => addProductToCart(productId, quantity),
// On success
onSuccess(result, { productId }) {
// Update cart
queryCache.invalidateQueries({ key: ['cart'] })
// Update product stock info
queryCache.invalidateQueries({ key: ['product-details', productId] })
// Show success message
added.value = true
setTimeout(() => {
added.value = false
}, 3000)
// Reset quantity
quantity.value = 1
},
// On error
onError(error) {
console.error('Error adding to cart:', error)
}
})
function addToCart() {
if (product.value.data) {
cartMutation.mutate({
productId: product.value.data.id,
quantity: quantity.value
})
}
}
</script>
Now let's add the feature to filter products by categories:
<template>
<div class="category-filter">
<h2>π·οΈ Categories</h2>
<!-- Category Buttons -->
<div class="category-buttons">
<button
v-for="category in categories"
:key="category.id"
@click="selectedCategory = category.id"
:class="{ active: selectedCategory === category.id }"
class="category-button"
>
{{ category.icon }} {{ category.name }}
</button>
</div>
<!-- Filtered Products -->
<div v-if="categoryProducts.state.data" class="filtered-products">
<h3>
{{ findCategory(selectedCategory).name }} Category
({{ categoryProducts.state.data.length }} products)
</h3>
<!-- Loading indicator -->
<div v-if="categoryProducts.asyncStatus === 'loading'" class="filter-loading">
π Applying filter...
</div>
<!-- Product List -->
<div class="product-grid">
<div
v-for="product in categoryProducts.state.data"
:key="product.id"
class="product-card"
>
<img :src="product.image" :alt="product.name" />
<h4>{{ product.name }}</h4>
<p class="price">${{ product.price }}</p>
<button @click="quickAddToCart(product.id)" class="quick-add">
β‘ Quick Add
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
import { fetchProductsByCategory } from '@/api/products'
import { addProductToCart } from '@/api/cart'
const queryCache = useQueryCache()
// Categories
const categories = [
{ id: 'electronics', name: 'Electronics', icon: 'π±' },
{ id: 'clothing', name: 'Clothing', icon: 'π' },
{ id: 'books', name: 'Books', icon: 'π' },
{ id: 'toys', name: 'Toys', icon: 'π§Έ' },
{ id: 'sports', name: 'Sports', icon: 'β½' }
]
const selectedCategory = ref('electronics')
// Category-based product query - with reactive key
const categoryProducts = useQuery({
key: () => ['category-products', selectedCategory.value],
query: () => fetchProductsByCategory(selectedCategory.value),
staleTime: 2 * 60 * 1000, // 2 minutes
})
// Quick add to cart
const quickAddMutation = useMutation({
mutation: (productId) => addProductToCart(productId, 1),
onSuccess() {
queryCache.invalidateQueries({ key: ['cart'] })
alert('β
Product added to cart!')
}
})
function findCategory(categoryId) {
return categories.find(c => c.id === categoryId)
}
function quickAddToCart(productId) {
quickAddMutation.mutate(productId)
}
// New data is automatically fetched when category changes
watch(selectedCategory, (newCategory) => {
console.log('π Category changed:', newCategory)
// Pinia Colada automatically fetches new data!
})
</script>
Now we've reached the most exciting part! We'll use "optimistic updates" in cart management. What does this mean? When a user adds something to their cart, we'll show it immediately without waiting for server confirmation!
<template>
<div class="cart">
<h2>π My Cart</h2>
<!-- Cart Summary -->
<div class="cart-summary">
<p>π¦ Total Items: {{ cartData.data?.items?.length || 0 }}</p>
<p>π° Total Amount: ${{ cartData.data?.totalAmount || 0 }}</p>
</div>
<!-- Cart Contents -->
<div v-if="cartData.data?.items" class="cart-items">
<div
v-for="item in cartData.data.items"
:key="item.id"
class="cart-item"
>
<img :src="item.product.image" :alt="item.product.name" />
<div class="product-info">
<h4>{{ item.product.name }}</h4>
<p>${{ item.product.price }}</p>
</div>
<!-- Quantity Control -->
<div class="quantity-control">
<button @click="updateQuantity(item.id, item.quantity - 1)">β</button>
<span>{{ item.quantity }}</span>
<button @click="updateQuantity(item.id, item.quantity + 1)">β</button>
</div>
<p class="subtotal">${{ item.product.price * item.quantity }}</p>
<button @click="removeItem(item.id)" class="remove-button">
ποΈ
</button>
</div>
</div>
<!-- Updating indicator -->
<div v-if="hasActiveUpdates" class="updating-banner">
β³ Cart is updating...
</div>
<!-- Empty Cart -->
<div v-else class="empty-cart">
<p>π
Your cart is empty!</p>
<router-link to="/products">ποΈ Start Shopping</router-link>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useQuery, useMutation, useQueryCache, useMutationState } from '@pinia/colada'
import { fetchCart, updateCartItem, removeCartItem } from '@/api/cart'
const queryCache = useQueryCache()
// Fetch cart data
const cartData = useQuery({
key: ['cart'],
query: fetchCart,
refetchOnWindowFocus: true, // Update when window gets focus
staleTime: 30_000, // Fresh for 30 seconds
})
// Quantity update mutation
const quantityUpdateMutation = useMutation({
key: ['cart-update'], // Key for state tracking
mutation: ({ itemId, newQuantity }) => updateCartItem(itemId, newQuantity),
// Optimistic Update - Show immediately in UI!
onMutate({ itemId, newQuantity }) {
// Get current cart data
const currentCart = queryCache.getQueryData(['cart'])
if (currentCart?.items) {
// Calculate new data
const updatedItems = currentCart.items.map(item =>
item.id === itemId ? { ...item, quantity: newQuantity } : item
)
// Update total amount
const newTotal = updatedItems.reduce(
(total, item) => total + (item.product.price * item.quantity),
0
)
// Update cache immediately (Optimistic Update!)
queryCache.setQueryData(['cart'], {
...currentCart,
items: updatedItems,
totalAmount: newTotal
})
}
// Save old data (to restore on error)
return { oldCart: currentCart }
},
// Always refetch cart in the end
onSettled() {
queryCache.invalidateQueries(['cart'])
},
// Restore old data on error
onError(error, { itemId, newQuantity }, context) {
console.error('Update error:', error)
if (context?.oldCart) {
queryCache.setQueryData(['cart'], context.oldCart)
}
}
})
// Item removal mutation
const itemRemovalMutation = useMutation({
key: ['cart-remove'],
mutation: (itemId) => removeCartItem(itemId),
// Optimistic Update - Remove immediately!
onMutate(itemId) {
const currentCart = queryCache.getQueryData(['cart'])
if (currentCart?.items) {
const updatedItems = currentCart.items.filter(
item => item.id !== itemId
)
queryCache.setQueryData(['cart'], {
...currentCart,
items: updatedItems,
totalAmount: updatedItems.reduce(
(total, item) => total + (item.product.price * item.quantity),
0
)
})
}
return { oldCart: currentCart }
},
onSuccess() {
queryCache.invalidateQueries(['cart'])
},
onError(error, itemId, context) {
if (context?.oldCart) {
queryCache.setQueryData(['cart'], context.oldCart)
}
}
})
// Track active mutations
const updateStatus = useMutationState({ key: ['cart-update'] })
const removeStatus = useMutationState({ key: ['cart-remove'] })
const hasActiveUpdates = computed(() =>
updateStatus.asyncStatus === 'loading' ||
removeStatus.asyncStatus === 'loading'
)
// Helper functions
function updateQuantity(itemId, newQuantity) {
if (newQuantity < 1) return
quantityUpdateMutation.mutate({ itemId, newQuantity })
}
function removeItem(itemId) {
if (confirm('Are you sure you want to remove this item from your cart?')) {
itemRemovalMutation.mutate(itemId)
}
}
</script>
One of Pinia Colada's most powerful features is prefetchQuery
. This allows you to load data in the background before users actually need it, creating an incredibly smooth user experience!
<template>
<div class="smart-product-list">
<h2>π§ Smart Product List with Prefetching</h2>
<div class="product-grid">
<div
v-for="product in products.data"
:key="product.id"
class="product-card"
@mouseenter="preloadProductDetails(product.id)"
@click="navigateToProduct(product.id)"
>
<img :src="product.image" :alt="product.name" />
<h3>{{ product.name }}</h3>
<p class="price">${{ product.price }}</p>
<!-- Show if data is already prefetched -->
<div v-if="isPrefetched(product.id)" class="prefetched-indicator">
β‘ Ready to view instantly!
</div>
</div>
</div>
<!-- Prefetch Statistics -->
<div class="prefetch-stats">
<h4>π Prefetch Statistics</h4>
<p>Products prefetched: {{ prefetchedCount }}</p>
<p>Cache hit rate: {{ cacheHitRate }}%</p>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useQuery, useQueryCache } from '@pinia/colada'
import { useRouter } from 'vue-router'
import { fetchAllProducts, fetchProductDetails } from '@/api/products'
const router = useRouter()
const queryCache = useQueryCache()
const prefetchedIds = ref(new Set())
const prefetchAttempts = ref(0)
const cacheHits = ref(0)
// Main products query
const products = useQuery({
key: ['products'],
query:
```javascript
// composables/useProducts.js
import { computed } from 'vue'
import { useQuery, useQueryCache, defineQueryOptions } from '@pinia/colada'
import { fetchAllProducts, fetchProductsByCategory } from '@/api/products'
// Define query options
export const productsQueryOptions = defineQueryOptions(() => ({
key: ['products'],
query: fetchAllProducts,
staleTime: 5 * 60 * 1000, // Fresh for 5 minutes
gcTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
}))
// Reusable composable
export function useProducts() {
const queryCache = useQueryCache()
const {
state: allProducts,
asyncStatus: isLoading,
refresh
} = useQuery(productsQueryOptions)
// Computed values
const productCount = computed(() => allProducts.value.data?.length || 0)
const hasProducts = computed(() => productCount.value > 0)
// Helper functions
function findProduct(id) {
return allProducts.value.data?.find(product => product.id === id)
}
// Preload a product (useful on hover!)
function preloadProduct(id) {
const product = findProduct(id)
if (product) {
queryCache.setQueryData(['product-details', id], product)
}
}
return {
allProducts,
productCount,
hasProducts,
isLoading,
refresh,
findProduct,
preloadProduct
}
}
We want our data to always be current. Pinia Colada makes this very easy:
<template>
<div class="live-sales-data">
<h3>π Live Sales Statistics</h3>
<!-- Statistics -->
<div v-if="salesData.data" class="statistics">
<div class="stat-card">
<h4>Today's Sales</h4>
<p class="big-number">{{ salesData.data.todaySales }}</p>
</div>
<div class="stat-card">
<h4>Active Users</h4>
<p class="big-number">{{ salesData.data.activeUsers }}</p>
</div>
<div class="stat-card">
<h4>Items in Cart</h4>
<p class="big-number">{{ salesData.data.itemsInCart }}</p>
</div>
</div>
<!-- Status Indicators -->
<div class="status-info">
<p>β° Last Update: {{ formatDate(lastUpdate) }}</p>
<p>π Next Update: {{ remainingTime }} seconds</p>
<label>
<input v-model="autoRefresh" type="checkbox" />
Auto Refresh (every 30 seconds)
</label>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useQuery } from '@pinia/colada'
import { fetchLiveSalesData } from '@/api/dashboard'
const autoRefresh = ref(true)
const lastUpdate = ref(new Date())
const counter = ref(30)
let counterInterval = null
// Live sales data - with auto refresh
const salesData = useQuery({
key: ['live-sales-data'],
query: async () => {
const data = await fetchLiveSalesData()
lastUpdate.value = new Date()
counter.value = 30
return data
},
// Auto refresh settings
refetchInterval: computed(() =>
autoRefresh.value ? 30_000 : false
),
refetchOnWindowFocus: true, // When window gets focus
refetchOnReconnect: true, // When internet connection returns
staleTime: 10_000, // Fresh for 10 seconds
})
const remainingTime = computed(() => counter.value)
function formatDate(date) {
return new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(date)
}
// For counter
onMounted(() => {
counterInterval = setInterval(() => {
if (counter.value > 0) counter.value--
}, 1000)
})
onUnmounted(() => {
if (counterInterval) clearInterval(counterInterval)
})
</script>
Sometimes things can go wrong. Pinia Colada makes error management easy:
// Global error management - main.js
import { PiniaColadaQueryHooksPlugin } from '@pinia/colada'
app.use(PiniaColadaQueryHooksPlugin, {
onError(error, query) {
console.error('Query error:', query.key, error)
// Show notification to user
if (error.status === 404) {
showNotification('Content you are looking for was not found π’', 'error')
} else if (error.status === 500) {
showNotification('There is a problem with the server, please try again later π§', 'error')
} else if (error.message.includes('Network')) {
showNotification('Check your internet connection π‘', 'warning')
}
}
})
// Query with custom retry logic
const { data, error, refetch } = useQuery({
key: ['critical-data'],
query: fetchCriticalData,
// Retry settings
retry: 3, // Try 3 times
retryDelay: (attemptIndex) => {
// Increase wait time with each attempt
// 1st attempt: 1 second
// 2nd attempt: 2 seconds
// 3rd attempt: 4 seconds
return Math.min(1000 * Math.pow(2, attemptIndex), 30000)
},
// Custom error check
retryOnError: (error) => {
// Don't retry on 404 errors
if (error.status === 404) return false
// Retry on other errors
return true
}
})
In e-commerce sites, there can be too many products. Instead of loading all at once, let's load them as the user scrolls down:
<template>
<div class="infinite-product-list">
<h2>π Infinite Product List</h2>
<!-- Products -->
<div class="product-grid">
<div
v-for="product in allProducts"
:key="product.id"
class="product-card"
>
<img :src="product.image" :alt="product.name" />
<h4>{{ product.name }}</h4>
<p>${{ product.price }}</p>
</div>
</div>
<!-- Loading indicator -->
<div ref="observerTarget" class="loading-indicator">
<div v-if="loadingMore">
π Loading more products...
</div>
<div v-else-if="!hasMore">
β
All products loaded!
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useInfiniteQuery } from '@pinia/colada'
import { fetchPaginatedProducts } from '@/api/products'
const observerTarget = ref(null)
let observer = null
// Infinite query
const {
state: pages,
loadMore,
asyncStatus
} = useInfiniteQuery({
key: ['infinite-products'],
// Query function for each page
query: async ({ pageParam }) => {
if (pageParam === null) return null
const result = await fetchPaginatedProducts({
page: pageParam,
limit: 20
})
return {
products: result.data,
nextPage: result.nextPage
}
},
// Initial page parameter
initialPageParam: 1,
// Determine next page parameter
getNextPageParam: (lastPage) => {
return lastPage?.nextPage || null
}
})
// Computed values
const allProducts = computed(() => {
if (!pages.value.data) return []
return pages.value.data.pages
.filter(page => page !== null)
.flatMap(page => page.products)
})
const loadingMore = computed(() =>
asyncStatus.value === 'loading'
)
const hasMore = computed(() => {
if (!pages.value.data) return true
const lastPage = pages.value.data.pages[pages.value.data.pages.length - 1]
return lastPage?.nextPage !== null
})
// Intersection Observer setup
onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
const target = entries[0]
if (target.isIntersecting && hasMore.value && !loadingMore.value) {
loadMore()
}
},
{ threshold: 0.5 }
)
if (observerTarget.value) {
observer.observe(observerTarget.value)
}
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
}
})
</script>
Writing tests is very important to ensure our code works properly. Testing with Pinia Colada is also very easy:
// tests/ProductList.test.js
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { mount } from '@vue/test-utils'
import { useQuery } from '@pinia/colada'
import ProductList from '@/components/ProductList.vue'
describe('ProductList Component', () => {
beforeEach(() => {
// Create a new Pinia instance before each test
setActivePinia(createPinia())
})
it('successfully loads and displays products', async () => {
// Mock data
const mockProducts = [
{ id: 1, name: 'Laptop', price: 1500 },
{ id: 2, name: 'Mouse', price: 20 }
]
// Mock API call
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockProducts)
})
global.fetch = mockFetch
// Mount component
const wrapper = mount(ProductList)
// Wait a bit (for query to complete)
await new Promise(resolve => setTimeout(resolve, 100))
// Check that products are displayed
expect(wrapper.text()).toContain('Laptop')
expect(wrapper.text()).toContain('$1500')
expect(wrapper.text()).toContain('Mouse')
expect(wrapper.text()).toContain('$20')
})
it('handles error state properly', async () => {
// Faulty API call
const mockFetch = vi.fn().mockRejectedValue(
new Error('Server error')
)
global.fetch = mockFetch
const wrapper = mount(ProductList)
await new Promise(resolve => setTimeout(resolve, 100))
// Check that error message is displayed
expect(wrapper.text()).toContain('Something went wrong')
expect(wrapper.find('.refresh-button').exists()).toBe(true)
})
})
Keep your query keys hierarchical and organized:
// β
Good example
const QUERY_KEYS = {
products: {
all: ['products'],
detail: (id) => ['products', 'detail', id],
category: (category) => ['products', 'category', category],
search: (term) => ['products', 'search', term]
},
cart: ['cart'],
user: {
profile: ['user', 'profile'],
orders: ['user', 'orders']
}
}
// Usage
useQuery({
key: QUERY_KEYS.products.detail(productId),
query: () => fetchProductDetails(productId)
})
Don't use optimistic updates everywhere. Only use them where they would improve user experience:
// β
Good usage - Adding to cart (quick feedback is important)
useMutation({
mutation: addToCart,
onMutate: () => {
// Show immediately in UI
}
})
// β Bad usage - Payment processing (security is important)
useMutation({
mutation: processPayment,
// DON'T use optimistic update!
// Wait for server confirmation
})
Set cache times according to how often the data changes:
// Frequently changing data (stock status)
useQuery({
key: ['stock', productId],
query: () => fetchStockStatus(productId),
staleTime: 30_000, // 30 seconds
gcTime: 5 * 60_000 // 5 minutes
})
// Rarely changing data (category list)
useQuery({
key: ['categories'],
query: fetchCategories,
staleTime: 24 * 60 * 60_000, // 24 hours
gcTime: 7 * 24 * 60 * 60_000 // 1 week
})
Similarities:
- Similar API design
- Cache management
- Optimistic updates
Pinia Colada Advantages:
- Optimized for Vue
- Smaller size (~2kb vs ~25kb)
- Natural integration with Vue reactivity system
- Simpler setup
Apollo Client:
- GraphQL focused
- More complex
- Larger size
Pinia Colada:
- Perfect for REST APIs
- Simple and understandable
- Supports GraphQL but not mandatory
Pinia Colada is an amazing tool that makes data management in modern Vue.js applications easier. Here are its main advantages:
- π Performance: Automatic cache and request deduplication
- π Easy to Use: Simple API, less code
- π― Vue-Specific: Optimized for Vue 3 and Composition API
- π¦ Small Size: Only ~2kb
- π§ Powerful Features: Optimistic updates, SSR support
- π§ͺ Testable: Easy to write tests
- π Good Documentation: Easy to learn
- β Anyone using Vue 3
- β Those building modern, reactive applications
- β Data-intensive applications like e-commerce, dashboards
- β Performance-conscious developers
- β Clean code enthusiasts
- Beginner: Start with simple examples in this article
- Practice: Try it in your own projects
- Deepen: Read the official documentation
- Master: Explore advanced features
With Pinia Colada, data management in your Vue.js applications is now much easier and more fun! Go ahead, build your own online store and discover the power of this amazing tool! π
π‘ Tip: All code examples in this article are sequential. You can build a complete e-commerce application by following from start to finish!
Happy coding! π