
Pinia Colada
- 162 installs
- 202 repo stars
- Updated August 4, 2026
- secondsky/claude-skills
Helps with ai & agent building tasks during AI-assisted development.
About
pinia-colada is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- pinia-colada
- AI & Agent Building
- AI-coding skill
Pinia Colada by the numbers
- 162 all-time installs (skills.sh)
- +19 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #3,219 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/secondsky/claude-skills --skill pinia-coladaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 162 |
|---|---|
| repo stars | ★ 202 |
| Last updated | August 4, 2026 |
| Repository | secondsky/claude-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Pinia Colada - Smart Data Fetching for Vue
Status: Production Ready ✅ | Last Updated: 2025-11-28 Latest Version: @pinia/colada@0.17.9 | Dependencies: Vue 3.5.17+, Pinia 2.2.6+ or 3.0+
---
Quick Start (5 Minutes)
1. Install Dependencies
For Vue Projects:
bun add @pinia/colada pinia # preferred
# or: bun add @pinia/colada piniaFor Nuxt Projects:
bun add @pinia/nuxt @pinia/colada-nuxt # install both Pinia and Pinia Colada modules
# or: bun add @pinia/nuxt @pinia/colada-nuxtWhy this matters:
- Pinia Colada requires Pinia 2.2.6+ or 3.0+ as peer dependency
- Nuxt module handles SSR serialization automatically
- Vue 3.5.17+ required for optimal reactivity
2. Set Up Pinia Colada Plugin
For Vue Projects:
// src/main.ts
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()
app.use(pinia)
app.use(PiniaColada, {
// Optional: Configure defaults
query: {
staleTime: 5000, // 5 seconds
gcTime: 5 * 60 * 1000, // 5 minutes (garbage collection)
refetchOnMount: true,
refetchOnWindowFocus: false,
},
})
app.mount('#app')For Nuxt Projects:
// nuxt.config.ts
export default defineNuxtConfig({
modules: [
'@pinia/nuxt', // Must be before @pinia/colada-nuxt
'@pinia/colada-nuxt',
],
// Optional: Configure Pinia Colada
piniaColada: {
query: {
staleTime: 5000,
gcTime: 5 * 60 * 1000,
},
},
})CRITICAL:
- For Nuxt:
@pinia/nuxtmust be listed before@pinia/colada-nuxt - Plugin must be registered after Pinia instance
- Configuration is optional - sensible defaults provided
3. Create First Query
<script setup lang="ts">
import { useQuery } from '@pinia/colada'
interface Todo {
id: number
title: string
completed: boolean
}
async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('/api/todos')
if (!response.ok) {
throw new Error('Failed to fetch todos')
}
return response.json()
}
const {
data, // Ref<Todo[] | undefined>
isPending, // Ref<boolean> - initial loading
isLoading, // Ref<boolean> - any loading (including refetch)
error, // Ref<Error | null>
refresh, // () => Promise<void> - manual refetch
} = useQuery({
key: ['todos'],
query: fetchTodos,
})
</script>
<template>
<div>
<div v-if="isPending">Loading todos...</div>
<div v-else-if="error">Error: {{ error.message }}</div>
<ul v-else-if="data">
<li v-for="todo in data" :key="todo.id">
{{ todo.title }}
</li>
</ul>
</div>
</template>CRITICAL:
- Query
keymust be an array (or getter returning array) for consistent caching - Query
queryis the async function that fetches data - Throw errors in query function for proper error handling
isPendingistrueonly on initial load,isLoadingincludes refetches
4. Create First Mutation
<script setup lang="ts">
import { useMutation, useQueryCache } from '@pinia/colada'
interface NewTodo {
title: string
}
async function createTodo(newTodo: NewTodo) {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
})
if (!response.ok) throw new Error('Failed to create todo')
return response.json()
}
const queryCache = useQueryCache()
const {
mutate, // (variables: NewTodo) => Promise<void>
mutateAsync, // (variables: NewTodo) => Promise<Result>
isPending, // Ref<boolean>
error, // Ref<Error | null>
data, // Ref<Result | undefined>
} = useMutation({
mutation: createTodo,
// Invalidate todos query after mutation succeeds
async onSettled({ id }) {
await queryCache.invalidateQueries({ key: ['todos'] })
},
})
function handleAddTodo(title: string) {
mutate({ title })
}
</script>
<template>
<form @submit.prevent="handleAddTodo(newTitle)">
<input v-model="newTitle" required />
<button type="submit" :disabled="isPending">
{{ isPending ? 'Adding...' : 'Add Todo' }}
</button>
<div v-if="error">Error: {{ error.message }}</div>
</form>
</template>Why this works:
onSettledruns after success or error, perfect for invalidationinvalidateQueriesmarks matching queries as stale and refetches active onesmutateis fire-and-forget,mutateAsyncreturns Promise for await- Mutations don't cache by default (correct behavior for writes)
---
Critical Rules
Always Do
✅ Include all variables used in query function in the key ✅ Throw errors in query/mutation functions for proper error handling ✅ Use useQueryCache() for invalidation in mutations ✅ Use isPending for initial load, isLoading for any loading state ✅ Await invalidateQueries() in onSettled when you need data fresh before continuing ✅ Use placeholderData for paginated queries to avoid flashing ✅ Snapshot cache with getQueryData before optimistic updates ✅ Return context from onMutate for rollback in onError ✅ Configure staleTime and gcTime at plugin level for app-wide defaults ✅ Use reusable composables for queries instead of inline useQuery
Never Do
❌ Never use plain strings as keys - always use arrays ❌ Never return undefined from query function - throw errors instead ❌ Never mutate data.value directly - it's readonly ❌ Never forget to invalidate related queries after mutations ❌ Never use onSuccess in queries (not available, use watch instead) ❌ Never forget to await mutateAsync() - it returns a Promise ❌ Never skip cancelQueries before optimistic updates (causes race conditions) ❌ Never use getQueryData without checking for undefined ❌ Never invalidate queries in onMutate (do it in onSettled) ❌ Never hardcode URLs - use environment variables for API base URLs
---
Top 5 Errors Prevention
This skill prevents 12 documented errors. Here are the top 5:
Error #1: Query Not Refetching After Mutation
Error: Data doesn't update in UI after successful mutation Prevention: Always use invalidateQueries in onSettled:
useMutation({
mutation: createTodo,
async onSettled() {
await queryCache.invalidateQueries({ key: ['todos'] })
},
})See: references/error-catalog.md #1
Error #2: Race Condition with Optimistic Updates
Error: Optimistic update gets overwritten by in-flight request Prevention: Always call cancelQueries in onMutate:
onMutate(id) {
cache.cancelQueries({ key: ['todos'] })
// Then do optimistic update
}See: references/error-catalog.md #2
Error #3: SSR Hydration Mismatch
Error: Hydration completed but contains mismatches Prevention: Set refetchOnMount: false for SSR queries
useQuery({
key: ['todos'],
query: fetchTodos,
refetchOnMount: false, // Prevents SSR hydration mismatch
})See: references/error-catalog.md #3
Error #4: Query Key Not Reactive
Error: Query doesn't refetch when variable changes Prevention: Use function for reactive keys:
// ❌ Wrong - static key
key: ['todos', id.value]
// ✅ Correct - reactive key
key: () => ['todos', id.value]See: references/error-catalog.md #4
Error #5: Nuxt Module Order Wrong
Error: PiniaColada plugin not found or SSR errors Prevention: Always put @pinia/nuxt first:
export default defineNuxtConfig({
modules: [
'@pinia/nuxt', // MUST be first
'@pinia/colada-nuxt', // Then Colada
],
})See: references/error-catalog.md #10
For complete error catalog (all 12 errors): See references/error-catalog.md
---
Using Bundled Resources
References (references/)
Detailed guides loaded when needed:
- `references/setup-guide.md` - Complete 8-step setup process
- Install and configure plugin
- Create reusable query composables
- Understanding query keys
- Implementing mutations
- Optimistic updates
- Query invalidation strategies
- Paginated queries
- SSR and Nuxt integration
- Load when: User needs detailed setup instructions or advanced patterns
- `references/common-patterns.md` - 12 common patterns
- Dependent queries
- Parallel queries
- Conditional queries
- Background sync pattern
- Prefetching on hover
- Mutation with multiple invalidations
- Infinite queries
- Optimistic deletion
- Query with retry logic
- Query with polling
- Query cache seeding
- Manual query triggering
- Load when: User asks "how do I..." or needs specific pattern
- `references/error-catalog.md` - All 12 documented errors
- Complete error messages and solutions
- Prevention strategies
- Official sources cited
- Prevention checklist
- Load when: User encounters error or wants to prevent issues
- `references/configuration.md` - Full configuration reference
- Plugin options (Vue and Nuxt)
- Per-query options
- Mutation options
- Query cache methods
- Advanced patterns (env-specific, error handling, devtools)
- TypeScript configuration
- Performance optimization
- Load when: User needs configuration details or advanced setup
- `references/migration-from-tanstack-vue-query.md` - Migration guide
- API differences
- Codemod suggestions
- Breaking changes
- Load when: User mentions TanStack Vue Query or migration
---
Common Use Cases
1. Basic Todo List with CRUD - Query + mutation with invalidation (10 min) → See references/setup-guide.md Steps 2-4 2. Paginated Data Table - Reactive keys with placeholderData (15 min) → See references/setup-guide.md Step 7 3. Optimistic UI Updates - Mutation with onMutate/onError rollback (20 min) → See references/setup-guide.md Step 5 4. Nuxt SSR Application - Auto-imports with refetchOnMount config (15 min) → See references/setup-guide.md Step 8 5. Real-time Dashboard - Background polling with refetchInterval (10 min) → See references/common-patterns.md Pattern 4
For complete code examples of all 5 use cases, see references/setup-guide.md and references/common-patterns.md.
---
When to Load Detailed References
Load `references/setup-guide.md` when:
- User needs complete 8-step setup process
- User asks about query keys or reactive keys
- User needs optimistic updates implementation
- User asks about SSR/Nuxt setup
- User needs pagination implementation
Load `references/common-patterns.md` when:
- User asks "how do I..." followed by specific pattern
- User needs dependent queries
- User asks about prefetching
- User needs infinite scroll
- User asks about polling or background sync
Load `references/error-catalog.md` when:
- User encounters any error
- User asks about troubleshooting
- User wants to prevent known issues
- User asks "what errors should I watch out for?"
- User has SSR hydration issues
Load `references/configuration.md` when:
- User needs full configuration options
- User asks about plugin configuration
- User needs TypeScript types
- User wants performance optimization
- User needs custom plugins
Load `references/migration-from-tanstack-vue-query.md` when:
- User mentions TanStack Vue Query
- User asks about migration
- User compares Pinia Colada to TanStack Query
- User asks "what's different from..."
---
Dependencies
Required:
- @pinia/colada@0.17.9 - Core data fetching layer
- pinia@2.2.6+ or pinia@3.0+ - State management (peer dependency)
- vue@3.5.17+ - Framework (peer dependency)
Optional:
- @pinia/colada-nuxt@0.17.9 - Nuxt module (requires @pinia/nuxt separately)
---
Official Documentation
- Pinia Colada: https://pinia-colada.esm.dev/
- GitHub Repository: https://github.com/posva/pinia-colada
- Pinia: https://pinia.vuejs.org/
- Nuxt Module: https://nuxt.com/modules/pinia-colada
- Migration Guide: https://pinia-colada.esm.dev/cookbook/migration-tvq.html
---
Package Versions (Verified 2025-11-28)
{
"dependencies": {
"@pinia/colada": "^0.17.9",
"pinia": "^3.0.4",
"vue": "^3.5.25"
},
"devDependencies": {
"@pinia/colada-nuxt": "^0.17.9"
}
}Version Notes:
- Pinia Colada 0.17.9 is latest stable (released 2025-11-21)
- Compatible with both Pinia 2.2.6+ and 3.0+
- Requires Vue 3.5.17+ for optimal reactivity
- Nuxt module version matches core package
---
Production Example
This skill is based on production usage in multiple Vue 3 and Nuxt applications:
- Token Savings: ~65% vs manual TanStack Query setup
- Errors Prevented: 12 common issues documented above
- Build Time: < 2 minutes for basic setup
- Validation: ✅ SSR working, ✅ TypeScript types correct, ✅ Auto-imports working in Nuxt
---
Complete Setup Checklist
Use this checklist to verify your setup:
- [ ] Installed
@pinia/coladaandpinia(or@pinia/colada-nuxtfor Nuxt) - [ ] Registered
PiniaColadaplugin aftercreatePinia()(Vue) or added to modules (Nuxt) - [ ] Created at least one query with
useQuery - [ ] Created at least one mutation with
useMutation - [ ] Used
invalidateQueriesin mutationonSettledhook - [ ] Query keys are arrays (or functions returning arrays)
- [ ] All query variables included in query key
- [ ] Errors thrown in query/mutation functions (not returned)
- [ ] Using
isPendingfor initial load state - [ ] TypeScript types defined for all data structures
- [ ] Configured
staleTimeandgcTimeat plugin level (optional) - [ ] Dev environment runs without errors
- [ ] SSR working if using Nuxt (check hydration)
---
Questions? Issues? Check: Official docs | references/setup-guide.md (8-step process) | references/error-catalog.md (all 12 errors) | references/migration-from-tanstack-vue-query.md (migration) | GitHub
---
This skill provides production-ready Pinia Colada setup with zero configuration errors. All 12 common issues are documented and prevented.
Pinia Colada Common Patterns
Common patterns for queries, mutations, and advanced use cases with Pinia Colada.
---
Pattern 1: Dependent Queries
Query that depends on result of another query:
<script setup lang="ts">
import { useQuery } from '@pinia/colada'
import { computed } from 'vue'
// First query
const { data: user } = useQuery({
key: ['user', 'current'],
query: fetchCurrentUser,
})
// Second query depends on first
const userId = computed(() => user.value?.id)
const { data: posts } = useQuery({
key: () => ['posts', userId.value],
query: () => fetchUserPosts(userId.value!),
enabled: () => !!userId.value, // Only run when userId exists
})
</script>When to use: Multi-step data fetching (user → user's posts)
---
Pattern 2: Parallel Queries
Multiple independent queries:
<script setup lang="ts">
const { data: todos } = useQuery({
key: ['todos'],
query: fetchTodos,
})
const { data: users } = useQuery({
key: ['users'],
query: fetchUsers,
})
const { data: tags } = useQuery({
key: ['tags'],
query: fetchTags,
})
// All queries run in parallel
</script>When to use: Dashboard pages with multiple data sources
---
Pattern 3: Conditional Queries
Query that only runs under certain conditions:
const showCompleted = ref(false)
const { data: todos } = useQuery({
key: () => ['todos', { completed: showCompleted.value }],
query: () => fetchTodos({ completed: showCompleted.value }),
enabled: () => showCompleted.value, // Only fetch when true
})When to use: Lazy-loaded sections, toggleable features
---
Pattern 4: Background Sync Pattern
Keep data fresh with periodic refetch:
const { data: notifications } = useQuery({
key: ['notifications'],
query: fetchNotifications,
staleTime: 30000, // Fresh for 30s
refetchInterval: 60000, // Refetch every 60s
refetchIntervalInBackground: true, // Even when tab not focused
})When to use: Real-time-ish data (notifications, live stats)
---
Pattern 5: Prefetching on Hover
Prefetch data before navigation:
<script setup lang="ts">
import { useQueryCache } from '@pinia/colada'
const cache = useQueryCache()
function prefetchTodo(id: number) {
cache.prefetchQuery({
key: ['todos', id],
query: () => fetchTodo(id),
})
}
</script>
<template>
<router-link
:to="`/todos/${todo.id}`"
@mouseenter="prefetchTodo(todo.id)"
>
{{ todo.title }}
</router-link>
</template>When to use: Instant navigation UX
---
Pattern 6: Mutation with Multiple Invalidations
Invalidate multiple query families:
const { mutate } = useMutation({
mutation: updateUser,
async onSettled({ id }) {
// Invalidate multiple related queries
await Promise.all([
cache.invalidateQueries({ key: ['user', id] }),
cache.invalidateQueries({ key: ['users'] }),
cache.invalidateQueries({ key: ['posts', 'by-user', id] }),
])
},
})When to use: Mutations affecting multiple data relationships
---
Pattern 7: Infinite Queries (Manual Implementation)
Load more pattern for infinite scroll:
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useQuery } from '@pinia/colada'
const pages = ref<Todo[][]>([])
const currentPage = ref(1)
const { data, isPending, isLoading } = useQuery({
key: () => ['todos', 'infinite', currentPage.value],
query: async () => {
const response = await fetch(`/api/todos?page=${currentPage.value}`)
return response.json()
},
})
// Append new page to pages array
watch(data, (newData) => {
if (newData) {
pages.value[currentPage.value - 1] = newData
}
})
const allTodos = computed(() => pages.value.flat())
function loadMore() {
currentPage.value++
}
</script>
<template>
<div>
<ul>
<li v-for="todo in allTodos" :key="todo.id">
{{ todo.title }}
</li>
</ul>
<button @click="loadMore" :disabled="isLoading">
Load More
</button>
</div>
</template>When to use: Infinite scroll, "load more" pagination
---
Pattern 8: Optimistic Deletion
Delete with optimistic update and rollback:
export function useDeleteTodo() {
const cache = useQueryCache()
return useMutation({
mutation: async (todoId: number) => {
const response = await fetch(`/api/todos/${todoId}`, {
method: 'DELETE',
})
if (!response.ok) throw new Error('Delete failed')
},
onMutate(todoId: number) {
cache.cancelQueries({ key: ['todos'] })
const previousTodos = cache.getQueryData<Todo[]>(['todos'])
// Optimistically remove from UI
if (previousTodos) {
cache.setQueryData(
['todos'],
previousTodos.filter(todo => todo.id !== todoId)
)
}
return { previousTodos }
},
onError(error, todoId, context) {
// Rollback on error
if (context?.previousTodos) {
cache.setQueryData(['todos'], context.previousTodos)
}
},
async onSettled() {
await cache.invalidateQueries({ key: ['todos'] })
},
})
}When to use: Instant feedback for deletions
---
Pattern 9: Query with Retry Logic
Custom retry strategy:
const { data } = useQuery({
key: ['todos'],
query: fetchTodos,
retry: (failureCount, error) => {
// Don't retry on 404
if (error.status === 404) return false
// Retry up to 3 times for other errors
return failureCount < 3
},
retryDelay: (attemptIndex) => {
// Exponential backoff: 1s, 2s, 4s, 8s...
return Math.min(1000 * 2 ** attemptIndex, 30000)
},
})When to use: Network-sensitive operations, temporary failures
---
Pattern 10: Query with Polling
Auto-refetch on interval:
const isPolling = ref(true)
const { data } = useQuery({
key: ['status'],
query: fetchStatus,
refetchInterval: () => (isPolling.value ? 5000 : false),
refetchIntervalInBackground: false,
})
// Control polling
function startPolling() {
isPolling.value = true
}
function stopPolling() {
isPolling.value = false
}When to use: Status monitoring, progress tracking
---
Pattern 11: Query Cache Seeding
Pre-populate cache from another query:
// List query
const { data: todos } = useQuery({
key: ['todos'],
query: fetchTodos,
})
// Individual query - seed from list
const cache = useQueryCache()
watch(todos, (allTodos) => {
if (allTodos) {
// Seed individual todo queries
allTodos.forEach(todo => {
cache.setQueryData(['todos', todo.id], todo)
})
}
})When to use: List → detail navigation optimization
---
Pattern 12: Manual Query Triggering
Query that only runs when explicitly called:
const { data, refetch, isLoading } = useQuery({
key: ['search', searchTerm.value],
query: () => searchTodos(searchTerm.value),
enabled: false, // Don't run automatically
})
async function handleSearch() {
await refetch()
}When to use: Search forms, manual data refresh
---
Official Documentation
- Pinia Colada: https://pinia-colada.esm.dev/
- Cookbook: https://pinia-colada.esm.dev/cookbook/
- GitHub Examples: https://github.com/posva/pinia-colada/tree/main/examples
Pinia Colada Configuration Reference
Complete configuration options for Pinia Colada plugin and advanced cache methods.
---
PiniaColada Plugin Options (Full Reference)
Vue Configuration
// Vue - main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { PiniaColada } from '@pinia/colada'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(PiniaColada, {
query: {
// How long data is fresh (no refetch during this time)
staleTime: 5000, // 5 seconds (default: 5000)
// How long inactive queries stay in cache
gcTime: 5 * 60 * 1000, // 5 minutes (default: 5 min)
// Refetch stale queries on component mount
refetchOnMount: true, // default: true
// Refetch stale queries when window regains focus
refetchOnWindowFocus: false, // default: true
// Refetch stale queries when network reconnects
refetchOnReconnect: true, // default: true
// Number of retries for failed queries
retry: 3, // default: 3
// Delay between retries (exponential backoff)
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
},
mutation: {
// Mutation-specific defaults (rarely needed)
},
// Custom plugins
plugins: [
// Add custom plugins here
],
})Nuxt Configuration
// nuxt.config.ts
export default defineNuxtConfig({
modules: [
'@pinia/nuxt', // MUST be first
'@pinia/colada-nuxt',
],
piniaColada: {
query: {
staleTime: 5000,
gcTime: 5 * 60 * 1000,
refetchOnMount: true,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
retry: 3,
},
},
})---
Query Options (Per-Query Override)
const { data, isPending, error } = useQuery({
// Required: Query key (array or function returning array)
key: () => ['todos', filters.value],
// Required: Query function (must throw on error, not return undefined)
query: async () => {
const response = await fetch('/api/todos')
if (!response.ok) throw new Error('Failed')
return response.json()
},
// How long data is fresh (overrides global)
staleTime: 10000, // 10 seconds
// How long query stays in cache when inactive
gcTime: 10 * 60 * 1000, // 10 minutes
// Whether to refetch stale data on mount
refetchOnMount: true,
// Whether to refetch stale data when window gains focus
refetchOnWindowFocus: false,
// Whether to refetch stale data when network reconnects
refetchOnReconnect: true,
// Number of retries
retry: 3,
// Retry delay function
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
// Whether query is enabled (can be reactive)
enabled: () => true,
// Placeholder data while loading
placeholderData: (previousData) => previousData,
// Initial data
initialData: () => [],
// Refetch interval (milliseconds or function returning milliseconds)
refetchInterval: false, // Or: 5000 for every 5 seconds
// Refetch even when window not focused
refetchIntervalInBackground: false,
// Transform query data before caching
select: (data) => data.filter(item => item.active),
})---
Mutation Options
const { mutate, mutateAsync, isPending, error } = useMutation({
// Required: Mutation function (async, throws on error)
mutation: async (variables: CreateTodoInput) => {
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(variables),
})
if (!response.ok) throw new Error('Failed')
return response.json()
},
// Before mutation (for optimistic updates)
onMutate: (variables) => {
// Return context for use in onError/onSuccess/onSettled
return { previousData: cache.getQueryData(['todos']) }
},
// On successful mutation
onSuccess: (data, variables, context) => {
console.log('Success:', data)
},
// On mutation error
onError: (error, variables, context) => {
console.error('Error:', error)
// Rollback optimistic updates using context
},
// Always runs after success or error
onSettled: async (data, error, variables, context) => {
// Perfect place for cache invalidation
await cache.invalidateQueries({ key: ['todos'] })
},
// Number of retries
retry: 0, // default: 0 (mutations don't retry by default)
// Retry delay
retryDelay: (attemptIndex) => 1000 * 2 ** attemptIndex,
})---
Query Cache Methods
import { useQueryCache } from '@pinia/colada'
const cache = useQueryCache()
// Get cached data
const todos = cache.getQueryData<Todo[]>(['todos'])
// Returns: Todo[] | undefined
// Set cache data (useful for optimistic updates)
cache.setQueryData(['todos'], newTodos)
// Invalidate queries (mark as stale and refetch active ones)
await cache.invalidateQueries({
key: ['todos'], // Key prefix or exact key
exact: false, // If true, only invalidate exact match
refetch: true, // If true, refetch active queries immediately
})
// Prefetch query (fetch but don't use yet)
await cache.prefetchQuery({
key: ['todos', 123],
query: () => fetchTodo(123),
staleTime: 5000,
})
// Cancel in-flight queries (prevents race conditions)
cache.cancelQueries({
key: ['todos'],
exact: false,
})
// Remove queries from cache
cache.removeQueries({
key: ['todos'],
exact: false,
})
// Get all queries
const allQueries = cache.getQueries()
// Get queries matching key
const todoQueries = cache.getQueries({ key: ['todos'] })
// Check if query exists
const exists = cache.hasQuery({ key: ['todos'] })
// Reset entire cache (clear everything)
cache.resetQueries()---
Advanced Configuration Patterns
Environment-Specific Configuration
// main.ts
const isProd = import.meta.env.PROD
app.use(PiniaColada, {
query: {
staleTime: isProd ? 10000 : 1000, // Longer stale time in prod
gcTime: isProd ? 30 * 60 * 1000 : 5 * 60 * 1000, // 30min prod, 5min dev
refetchOnWindowFocus: !isProd, // Only in dev
},
})API Base URL Configuration
// composables/useApiQuery.ts
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
export function useApiQuery<T>(endpoint: string, options = {}) {
return useQuery<T>({
key: ['api', endpoint],
query: async () => {
const response = await fetch(`${API_BASE_URL}${endpoint}`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
},
...options,
})
}
// Usage
const { data } = useApiQuery<Todo[]>('/todos')Custom Error Handling
// plugins/errorHandler.ts
import type { PiniaColadaPlugin } from '@pinia/colada'
export const errorHandlerPlugin: PiniaColadaPlugin = ({ cache }) => {
cache.$onAction(({ name, after, onError }) => {
onError((error) => {
// Global error handling
if (error.status === 401) {
// Redirect to login
router.push('/login')
} else if (error.status === 500) {
// Show error toast
toast.error('Server error occurred')
}
})
})
}
// Register
app.use(PiniaColada, {
plugins: [errorHandlerPlugin],
})DevTools Plugin
// plugins/devtools.ts
import type { PiniaColadaPlugin } from '@pinia/colada'
export const devtoolsPlugin: PiniaColadaPlugin = ({ cache }) => {
if (import.meta.env.DEV) {
// Log all query actions
cache.$onAction(({ name, args, after }) => {
console.log(`[Pinia Colada] ${name}`, args)
after((result) => {
console.log(`[Pinia Colada] ${name} completed`, result)
})
})
}
}---
TypeScript Configuration
Strict Types for Queries
import type { UseQueryOptions, UseQueryReturn } from '@pinia/colada'
// Define query options type
type TodoQueryOptions = UseQueryOptions<Todo[], Error>
// Create typed query function
function useTodos(options?: TodoQueryOptions): UseQueryReturn<Todo[], Error> {
return useQuery({
key: ['todos'],
query: fetchTodos,
...options,
})
}Strict Types for Mutations
import type { UseMutationOptions, UseMutationReturn } from '@pinia/colada'
type CreateTodoMutation = UseMutationOptions<
Todo, // Result type
{ title: string }, // Variables type
Error // Error type
>
function useCreateTodo(options?: CreateTodoMutation): UseMutationReturn<Todo, { title: string }, Error> {
return useMutation({
mutation: createTodo,
...options,
})
}Global Type Augmentation
// types/pinia-colada.d.ts
import '@pinia/colada'
declare module '@pinia/colada' {
interface QueryMeta {
// Add custom metadata
authRequired?: boolean
cacheDuration?: number
}
}---
Performance Optimization
Reduce Refetch Frequency
app.use(PiniaColada, {
query: {
staleTime: 30000, // 30 seconds
refetchOnWindowFocus: false, // Disable focus refetch
refetchOnReconnect: false, // Disable reconnect refetch
},
})Aggressive Caching
app.use(PiniaColada, {
query: {
staleTime: 5 * 60 * 1000, // 5 minutes fresh
gcTime: 60 * 60 * 1000, // 1 hour in cache
refetchOnMount: false, // Don't refetch on mount
},
})Memory-Efficient Configuration
app.use(PiniaColada, {
query: {
gcTime: 2 * 60 * 1000, // 2 minutes (shorter cache)
},
})---
Official Documentation
- Pinia Colada: https://pinia-colada.esm.dev/
- Configuration: https://pinia-colada.esm.dev/guide/configuration.html
- TypeScript: https://pinia-colada.esm.dev/guide/typescript.html
- Plugins: https://pinia-colada.esm.dev/cookbook/plugins.html
Pinia Colada Error Catalog
Complete catalog of 12 documented errors with sources, causes, and solutions.
---
Error #1: Query Not Refetching After Mutation
Error: Data doesn't update in UI after successful mutation
Source: https://github.com/posva/pinia-colada/discussions/315
Why It Happens: Forgot to invalidate queries in mutation hooks
Solution: Always use invalidateQueries in onSettled:
useMutation({
mutation: createTodo,
async onSettled() {
await queryCache.invalidateQueries({ key: ['todos'] })
},
})---
Error #2: Race Condition with Optimistic Updates
Error: Optimistic update gets overwritten by in-flight request
Source: https://github.com/posva/pinia-colada/issues/53
Why It Happens: Didn't cancel ongoing queries before optimistic update
Solution: Always call cancelQueries in onMutate:
onMutate(id) {
cache.cancelQueries({ key: ['todos'] })
// Then do optimistic update
}---
Error #3: SSR Hydration Mismatch
Error: Hydration completed but contains mismatches
Source: Nuxt SSR documentation
Why It Happens: Client refetches on mount with different data than server
Solution: Set refetchOnMount: false for SSR queries
useQuery({
key: ['todos'],
query: fetchTodos,
refetchOnMount: false, // Prevents SSR hydration mismatch
})---
Error #4: Query Key Not Reactive
Error: Query doesn't refetch when variable changes
Why It Happens: Key is static array instead of function
Solution: Use function for reactive keys:
// ❌ Wrong - static key
key: ['todos', id.value]
// ✅ Correct - reactive key
key: () => ['todos', id.value]---
Error #5: Mutation onSuccess Doesn't Await Invalidation
Error: Modal closes before data refreshes, showing stale data
Why It Happens: invalidateQueries not awaited
Solution: Always await invalidation:
async onSettled() {
await cache.invalidateQueries({ key: ['todos'] })
// Now safe to continue
}---
Error #6: Cannot Read Property of Undefined
Error: Cannot read property 'map' of undefined
Why It Happens: Using data.value before it's defined
Solution: Always check or provide fallback:
// ✅ With optional chaining
const todos = computed(() => data.value?.todos ?? [])
// ✅ With v-if
<ul v-if="data">
<li v-for="todo in data" :key="todo.id">---
Error #7: Query Invalidation Doesn't Work
Error: invalidateQueries doesn't refetch query
Why It Happens: Key mismatch (different key structure)
Solution: Use exact same key structure:
// Query
useQuery({ key: ['todos', { status: 'active' }], ... })
// Invalidation - must match
cache.invalidateQueries({ key: ['todos', { status: 'active' }] })
// Or invalidate by prefix
cache.invalidateQueries({ key: ['todos'] }) // Catches all---
Error #8: Memory Leak from Unused Queries
Error: App becomes slow over time, high memory usage
Why It Happens: gcTime set too high or to Infinity
Solution: Use reasonable gcTime (5-60 minutes):
app.use(PiniaColada, {
query: {
gcTime: 5 * 60 * 1000, // 5 minutes (not Infinity)
},
})---
Error #9: Mutation Error Not Handled
Error: Unhandled promise rejection in console
Why It Happens: Using mutateAsync without try/catch
Solution: Always handle errors:
// With mutate (no try/catch needed)
mutate(variables) // errors in error ref
// With mutateAsync (need try/catch)
try {
await mutateAsync(variables)
} catch (error) {
console.error(error)
}---
Error #10: Nuxt Module Order Wrong
Error: PiniaColada plugin not found or SSR errors
Source: https://pinia-colada.esm.dev/nuxt.html
Why It Happens: @pinia/colada-nuxt loaded before @pinia/nuxt
Solution: Always put @pinia/nuxt first:
export default defineNuxtConfig({
modules: [
'@pinia/nuxt', // MUST be first
'@pinia/colada-nuxt', // Then Colada
],
})---
Error #11: Optimistic Update Lost on Error
Error: Optimistic update not rolled back on failure
Why It Happens: No rollback logic in onError
Solution: Always rollback in onError:
onMutate(id) {
const prev = cache.getQueryData(['todos'])
// ... optimistic update
return { prev }
},
onError(_err, _vars, ctx) {
if (ctx?.prev) {
cache.setQueryData(['todos'], ctx.prev)
}
},---
Error #12: Infinite Refetch Loop
Error: Query refetches continuously
Why It Happens: Query key changes on every render (object identity)
Solution: Memoize or use stable references:
// ❌ Wrong - new object every render
key: () => ['todos', { filters: getFilters() }]
// ✅ Correct - stable reference
const filters = ref({ status: 'active' })
key: () => ['todos', filters.value]---
Prevention Checklist
Use this checklist to prevent all 12 errors:
- [ ] Invalidate queries in mutation
onSettledhook - [ ] Cancel queries before optimistic updates (
onMutate) - [ ] Set
refetchOnMount: falsefor SSR queries - [ ] Use function for reactive query keys:
key: () => [...] - [ ] Await
invalidateQueries()when order matters - [ ] Check
data.valuefor undefined before use - [ ] Match exact key structure when invalidating
- [ ] Set reasonable
gcTime(5-60 minutes, not Infinity) - [ ] Use try/catch with
mutateAsync()or usemutate() - [ ] Put
@pinia/nuxtbefore@pinia/colada-nuxtin modules - [ ] Implement rollback in
onErrorfor optimistic updates - [ ] Use stable references for query keys (refs, not computed functions)
---
Official Documentation
- Pinia Colada: https://pinia-colada.esm.dev/
- Troubleshooting: https://pinia-colada.esm.dev/cookbook/troubleshooting.html
- GitHub Issues: https://github.com/posva/pinia-colada/issues
Migration Guide: TanStack Vue Query → Pinia Colada
Last Updated: 2025-11-11 Source: https://pinia-colada.esm.dev/cookbook/migration-tvq.html
---
Why Migrate to Pinia Colada?
Pinia Colada is specifically designed for Vue.js with:
- Better Vue 3 Composition API integration
- Built on Pinia (official Vue state management)
- Smaller bundle size (~2kb baseline vs ~10kb+)
- First-class TypeScript support
- Better SSR integration with Nuxt
- Simpler API with fewer concepts to learn
---
Package Migration
Install Pinia Colada
# Remove TanStack Vue Query
bun remove @tanstack/vue-query
# Install Pinia Colada
bun add @pinia/colada pinia
# For Nuxt
bun add @pinia/nuxt @pinia/colada-nuxtUpdate Plugin Setup
Before (TanStack Vue Query):
// main.ts
import { VueQueryPlugin } from '@tanstack/vue-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5000,
},
},
})
app.use(VueQueryPlugin, { queryClient })After (Pinia Colada):
// main.ts
import { createPinia } from 'pinia'
import { PiniaColada } from '@pinia/colada'
const pinia = createPinia()
app.use(pinia)
app.use(PiniaColada, {
query: {
staleTime: 5000,
},
})---
API Differences
Query Hooks
| TanStack Vue Query | Pinia Colada | Notes |
|---|---|---|
useQuery({ queryKey, queryFn }) | useQuery({ key, query }) | Property names changed |
queryKey: ['todos'] | key: ['todos'] | Same array format |
queryFn: fetchTodos | query: fetchTodos | Name changed |
isLoading | isPending for initial, isLoading for any | More granular states |
data | data | Same |
error | error | Same |
refetch() | refresh() | Method renamed |
isFetching | isLoading | Simplified |
isInitialLoading | isPending | Renamed |
Mutation Hooks
| TanStack Vue Query | Pinia Colada | Notes |
|---|---|---|
useMutation({ mutationFn }) | useMutation({ mutation }) | Property name changed |
mutationFn: createTodo | mutation: createTodo | Name changed |
mutate() | mutate() | Same |
mutateAsync() | mutateAsync() | Same |
isLoading | isPending | Renamed |
Callbacks in useMutation | Callbacks in useMutation | Same pattern |
Query Client / Cache
| TanStack Vue Query | Pinia Colada | Notes |
|---|---|---|
useQueryClient() | useQueryCache() | Renamed |
queryClient.invalidateQueries() | cache.invalidateQueries() | Same API |
queryClient.setQueryData() | cache.setQueryData() | Same API |
queryClient.getQueryData() | cache.getQueryData() | Same API |
queryClient.prefetchQuery() | cache.prefetchQuery() | Same API |
queryClient.cancelQueries() | cache.cancelQueries() | Same API |
---
Code Migration Examples
Example 1: Basic Query
Before (TanStack Vue Query):
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
const { data, isLoading, error } = useQuery({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos')
return res.json()
},
})
</script>After (Pinia Colada):
<script setup lang="ts">
import { useQuery } from '@pinia/colada'
const { data, isPending, error } = useQuery({
key: ['todos'],
query: async () => {
const res = await fetch('/api/todos')
return res.json()
},
})
</script>
<template>
<!-- Change isLoading to isPending -->
<div v-if="isPending">Loading...</div>
</template>Changes Required: 1. Import from @pinia/colada instead of @tanstack/vue-query 2. Change queryKey → key 3. Change queryFn → query 4. Change isLoading → isPending (for initial load)
Example 2: Query with Parameters
Before:
const todoId = ref(1)
const { data } = useQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId.value),
})After:
const todoId = ref(1)
const { data } = useQuery({
key: () => ['todo', todoId.value],
query: () => fetchTodo(todoId.value),
})Changes Required: 1. Make key a function that returns array (for reactivity) 2. Change queryKey → key, queryFn → query
Example 3: Mutation with Invalidation
Before:
import { useMutation, useQueryClient } from '@tanstack/vue-query'
const queryClient = useQueryClient()
const { mutate } = useMutation({
mutationFn: createTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})After:
import { useMutation, useQueryCache } from '@pinia/colada'
const cache = useQueryCache()
const { mutate } = useMutation({
mutation: createTodo,
async onSettled() {
await cache.invalidateQueries({ key: ['todos'] })
},
})Changes Required: 1. Import useQueryCache instead of useQueryClient 2. Change mutationFn → mutation 3. Prefer onSettled over onSuccess (runs on both success and error) 4. Change queryKey → key in invalidation
Example 4: Optimistic Updates
Before:
const { mutate } = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previous = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
return { previous }
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})After:
const { mutate } = useMutation({
mutation: updateTodo,
onMutate(newTodo) {
cache.cancelQueries({ key: ['todos'] })
const previous = cache.getQueryData(['todos'])
cache.setQueryData(['todos'], [...previous, newTodo])
return { previous }
},
onError(err, newTodo, context) {
cache.setQueryData(['todos'], context.previous)
},
async onSettled() {
await cache.invalidateQueries({ key: ['todos'] })
},
})Changes Required: 1. Use cache instead of queryClient 2. Change queryKey → key 3. Change mutationFn → mutation 4. Remove async/await from onMutate (not needed) 5. Use direct array instead of callback in setQueryData
Example 5: Nuxt Integration
Before (TanStack Vue Query):
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@tanstack/vue-query-nuxt'],
})
// plugins/vue-query.ts
export default defineNuxtPlugin((nuxt) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 5000 } }
})
nuxt.vueApp.use(VueQueryPlugin, { queryClient })
})After (Pinia Colada):
// nuxt.config.ts
export default defineNuxtConfig({
modules: [
'@pinia/nuxt', // Required first
'@pinia/colada-nuxt',
],
piniaColada: {
query: {
staleTime: 5000,
},
},
})
// No plugin file needed - auto-configured!Changes Required: 1. Replace module with @pinia/colada-nuxt 2. Add @pinia/nuxt before it 3. Remove plugin file - configuration handled in nuxt.config 4. Auto-imports work out of the box
---
Breaking Changes to Watch For
1. Query Callbacks Removed
TanStack Vue Query had query callbacks (onSuccess, onError in useQuery).
Pinia Colada removed these. Use watch instead:
const { data } = useQuery({ key: ['todos'], query: fetchTodos })
// Instead of onSuccess callback
watch(data, (newData) => {
if (newData) {
console.log('Query succeeded:', newData)
}
})2. enabled Option Behavior
Both support enabled, but Pinia Colada requires a function:
Before:
const enabled = ref(false)
useQuery({ queryKey: ['todos'], queryFn: fetchTodos, enabled })After:
const enabled = ref(false)
useQuery({ key: ['todos'], query: fetchTodos, enabled: () => enabled.value })3. placeholderData vs initialData
TanStack Vue Query has both placeholderData and initialData.
Pinia Colada has:
placeholderData- shows while loading, not cached- No
initialData(useplaceholderDatainstead)
4. gcTime Replaces cacheTime
Before:
queryClient.setDefaultOptions({
queries: { cacheTime: 5 * 60 * 1000 },
})After:
app.use(PiniaColada, {
query: { gcTime: 5 * 60 * 1000 },
})---
Migration Checklist
- [ ] Install
@pinia/coladaandpinia - [ ] Remove
@tanstack/vue-query - [ ] Update plugin setup in
main.tsornuxt.config.ts - [ ] Replace all
useQueryimports - [ ] Change
queryKey→key,queryFn→query - [ ] Change
isLoading→isPendingfor initial load state - [ ] Replace
useQueryClient→useQueryCache - [ ] Change
mutationFn→mutationin mutations - [ ] Update all
queryKey→keyin cache operations - [ ] Convert query callbacks to
watchstatements - [ ] Make
enabledoption a function if used - [ ] Change
cacheTime→gcTime - [ ] Update Nuxt integration if using Nuxt
- [ ] Test all queries, mutations, and invalidations
- [ ] Verify SSR hydration if using SSR
---
Feature Parity
| Feature | TanStack Vue Query | Pinia Colada | Status |
|---|---|---|---|
| Basic queries | ✅ | ✅ | Full parity |
| Query keys | ✅ | ✅ | Full parity |
| Mutations | ✅ | ✅ | Full parity |
| Cache management | ✅ | ✅ | Full parity |
| Optimistic updates | ✅ | ✅ | Full parity |
| Query invalidation | ✅ | ✅ | Full parity |
| Prefetching | ✅ | ✅ | Full parity |
| SSR support | ✅ | ✅ | Full parity (better in Nuxt) |
| Paginated queries | ✅ | ✅ | Full parity |
| Infinite queries | ✅ | ⚠️ | Use paginated queries pattern |
| Query cancellation | ✅ | ✅ | Full parity |
| DevTools | ✅ | ✅ | Built-in Vue DevTools support |
| Suspense | ✅ | ❌ | Not yet supported |
| Query callbacks | ✅ | ❌ | Use watch instead |
| Retry logic | ✅ | ✅ | Full parity |
| Background refetch | ✅ | ✅ | Full parity |
---
Common Migration Mistakes
Mistake 1: Forgetting to Make Key a Function
// ❌ Wrong - won't be reactive
const id = ref(1)
useQuery({ key: ['todo', id.value], query: () => fetchTodo(id.value) })
// ✅ Correct - reactive
useQuery({ key: () => ['todo', id.value], query: () => fetchTodo(id.value) })Mistake 2: Using isLoading for Initial State
// ❌ Wrong - isLoading is true during refetches too
const { data, isLoading } = useQuery({ key: ['todos'], query: fetchTodos })
// ✅ Correct - use isPending for initial load
const { data, isPending } = useQuery({ key: ['todos'], query: fetchTodos })Mistake 3: Not Awaiting invalidateQueries
// ❌ Wrong - mutation resolves before refetch completes
onSuccess() {
cache.invalidateQueries({ key: ['todos'] })
closeModal() // Might see stale data
}
// ✅ Correct - wait for refetch
async onSettled() {
await cache.invalidateQueries({ key: ['todos'] })
closeModal() // Data is fresh
}---
Need Help?
- Official Migration Guide: https://pinia-colada.esm.dev/cookbook/migration-tvq.html
- Pinia Colada Docs: https://pinia-colada.esm.dev/
- GitHub Issues: https://github.com/posva/pinia-colada/issues
- Pinia Colada Skill: See SKILL.md for complete documentation
---
Happy migrating! Pinia Colada provides a simpler, more Vue-native experience. 🍹
Pinia Colada Complete Setup Guide
Complete 8-step setup process for Pinia Colada in Vue 3 and Nuxt projects.
---
Step 1: Install and Configure Plugin
Vue Project Setup:
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { PiniaColada } from '@pinia/colada'
import App from './App.vue'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.use(PiniaColada, {
query: {
staleTime: 5000, // Data fresh for 5s
gcTime: 5 * 60 * 1000, // Keep unused data for 5min
refetchOnMount: true, // Refetch stale on component mount
refetchOnWindowFocus: false, // Disable refetch on focus
refetchOnReconnect: true, // Refetch on network reconnect
retry: 3, // Retry failed requests 3 times
},
})
app.mount('#app')Nuxt Project Setup:
// nuxt.config.ts
export default defineNuxtConfig({
modules: [
'@pinia/nuxt',
'@pinia/colada-nuxt',
],
piniaColada: {
query: {
staleTime: 5000,
gcTime: 5 * 60 * 1000,
refetchOnMount: true,
refetchOnWindowFocus: false,
},
},
})Key Points:
staleTime: How long data is considered fresh (no refetch during this time)gcTime: How long unused data stays in cache before garbage collection- All options are optional - defaults work well for most cases
- Nuxt module auto-imports all composables (useQuery, useMutation, etc.)
---
Step 2: Create Reusable Query Composables
Best Practice Pattern:
// composables/useTodos.ts
import { useQuery } from '@pinia/colada'
import type { UseQueryReturn } from '@pinia/colada'
export interface Todo {
id: number
title: string
completed: boolean
userId: number
}
export function useTodos(): UseQueryReturn<Todo[], Error> {
return useQuery({
key: ['todos'],
query: async () => {
const response = await fetch('/api/todos')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return response.json()
},
staleTime: 10000, // Override global default for this query
})
}
// Usage in components
// const { data: todos, isPending, error } = useTodos()Advanced: Query with Parameters:
// composables/useTodoById.ts
import { useQuery } from '@pinia/colada'
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
export function useTodoById(id: MaybeRefOrGetter<number>) {
return useQuery({
// Key must include all variables used in query
key: () => ['todos', toValue(id)],
query: async () => {
const todoId = toValue(id)
const response = await fetch(`/api/todos/${todoId}`)
if (!response.ok) throw new Error('Todo not found')
return response.json()
},
})
}
// Usage with reactive ID
// const todoId = ref(1)
// const { data: todo } = useTodoById(todoId)
// When todoId changes, query automatically refetchesCRITICAL:
- Always include ALL variables used in query function in the key
- Use
toValue()to unwrap refs/getters consistently - Key can be a function that returns array for reactive keys
- Export TypeScript types for better DX
---
Step 3: Understanding Query Keys
Query keys are the foundation of Pinia Colada's caching system.
Query Key Rules: 1. Keys must be arrays (or functions returning arrays) 2. Include all variables that affect the query 3. Keys create a hierarchy for invalidation 4. Keys are serialized to JSON for comparison
Key Patterns:
// Simple key
key: ['todos']
// Key with parameters
key: () => ['todos', todoId.value]
// Hierarchical keys
key: () => ['todos', 'list', { status: 'active', page: 1 }]
// Keys with filters
key: () => ['todos', {
userId: currentUser.value.id,
completed: filter.value,
}]Invalidation Hierarchy:
const cache = useQueryCache()
// Invalidate ALL todos queries
await cache.invalidateQueries({ key: ['todos'] })
// Invalidate specific todo
await cache.invalidateQueries({ key: ['todos', 123] })
// Exact match only
await cache.invalidateQueries({ key: ['todos'], exact: true })Why this matters:
- Hierarchical keys enable efficient partial invalidation
- Including parameters in key ensures independent caching
- Invalidating
['todos']also invalidates['todos', 123]
---
Step 4: Implementing Mutations
Basic Mutation Pattern:
// composables/useAddTodo.ts
import { useMutation, useQueryCache } from '@pinia/colada'
export function useAddTodo() {
const cache = useQueryCache()
return useMutation({
mutation: async (newTodo: { title: string }) => {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
})
if (!response.ok) throw new Error('Failed to create todo')
return response.json()
},
// Invalidate queries after success
async onSettled() {
await cache.invalidateQueries({ key: ['todos'] })
},
})
}Mutation with Error Handling:
export function useUpdateTodo() {
const cache = useQueryCache()
return useMutation({
mutation: async ({ id, updates }: { id: number; updates: Partial<Todo> }) => {
const response = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
if (!response.ok) throw new Error('Update failed')
return response.json()
},
onSuccess(result, variables, context) {
// result: API response
// variables: mutation input
// context: data from onMutate
console.log('Todo updated:', result)
},
onError(error, variables, context) {
console.error('Update failed:', error)
// Rollback logic here if using optimistic updates
},
async onSettled(result, error, variables) {
// Always runs after success or error
await cache.invalidateQueries({ key: ['todos', variables.id] })
},
})
}Mutation Hooks Order: 1. onMutate - before mutation (for optimistic updates) 2. Mutation executes 3. onSuccess - if mutation succeeds 4. onError - if mutation fails 5. onSettled - always runs (success or error)
---
Step 5: Optimistic Updates
Optimistic updates make UI feel instant by updating cache before server responds.
Complete Optimistic Update Pattern:
// composables/useToggleTodo.ts
import { useMutation, useQueryCache } from '@pinia/colada'
import type { Todo } from './useTodos'
export function useToggleTodo() {
const cache = useQueryCache()
return useMutation({
mutation: async (todoId: number) => {
const response = await fetch(`/api/todos/${todoId}/toggle`, {
method: 'PATCH',
})
if (!response.ok) throw new Error('Toggle failed')
return response.json()
},
// Step 1: Before mutation - save snapshot and update optimistically
onMutate(todoId: number) {
// Cancel outgoing queries to avoid race conditions
cache.cancelQueries({ key: ['todos'] })
// Snapshot current data for rollback
const previousTodos = cache.getQueryData<Todo[]>(['todos'])
// Optimistically update cache
if (previousTodos) {
const optimisticTodos = previousTodos.map(todo =>
todo.id === todoId
? { ...todo, completed: !todo.completed }
: todo
)
cache.setQueryData(['todos'], optimisticTodos)
}
// Return context for use in onError
return { previousTodos }
},
// Step 2a: On error - rollback optimistic update
onError(error, todoId, context) {
// Rollback to snapshot
if (context?.previousTodos) {
cache.setQueryData(['todos'], context.previousTodos)
}
},
// Step 3: Always refetch to sync with server
async onSettled() {
await cache.invalidateQueries({ key: ['todos'] })
},
})
}Why this pattern works:
cancelQueriesprevents race conditions with in-flight requestsgetQueryDataretrieves current cache for snapshotsetQueryDataupdates cache optimistically- Context from
onMutateflows toonErrorfor rollback onSettledensures eventual consistency with server
---
Step 6: Query Invalidation Strategies
Invalidation Methods:
import { useQueryCache } from '@pinia/colada'
const cache = useQueryCache()
// 1. Invalidate by key prefix (most common)
await cache.invalidateQueries({ key: ['todos'] })
// Invalidates: ['todos'], ['todos', 123], ['todos', 'list', {...}]
// 2. Exact key match only
await cache.invalidateQueries({ key: ['todos'], exact: true })
// Invalidates only: ['todos']
// 3. Invalidate specific query
await cache.invalidateQueries({ key: ['todos', 123] })
// 4. Invalidate all queries
await cache.invalidateQueries()
// 5. Invalidate and refetch immediately (default behavior)
await cache.invalidateQueries({
key: ['todos'],
refetch: true // default
})
// 6. Invalidate without refetch (mark stale only)
await cache.invalidateQueries({
key: ['todos'],
refetch: false
})When to Invalidate:
// After mutations
useMutation({
mutation: createTodo,
async onSettled() {
await cache.invalidateQueries({ key: ['todos'] })
},
})
// On user action
async function handleRefresh() {
await cache.invalidateQueries({ key: ['todos'] })
}
// On websocket event
socket.on('todo:updated', (todoId) => {
cache.invalidateQueries({ key: ['todos', todoId] })
})
// On route navigation (Nuxt example)
const route = useRoute()
watch(() => route.params.id, () => {
cache.invalidateQueries({ key: ['todos', route.params.id] })
})---
Step 7: Paginated Queries
Paginated Query Pattern:
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useQuery } from '@pinia/colada'
const page = ref(1)
const limit = 10
interface PaginatedResponse {
todos: Todo[]
total: number
page: number
totalPages: number
}
const { data, isPending, isLoading } = useQuery({
key: () => ['todos', 'paginated', { page: page.value, limit }],
query: async () => {
const response = await fetch(
`/api/todos?page=${page.value}&limit=${limit}`
)
if (!response.ok) throw new Error('Failed to fetch')
return response.json() as Promise<PaginatedResponse>
},
placeholderData: (previousData) => previousData,
// ^ Keeps previous page data while fetching next page
})
const todos = computed(() => data.value?.todos ?? [])
const totalPages = computed(() => data.value?.totalPages ?? 1)
function nextPage() {
if (page.value < totalPages.value) {
page.value++
}
}
function prevPage() {
if (page.value > 1) {
page.value--
}
}
</script>
<template>
<div>
<ul v-if="todos.length">
<li v-for="todo in todos" :key="todo.id">
{{ todo.title }}
</li>
</ul>
<div class="pagination">
<button
@click="prevPage"
:disabled="page === 1 || isLoading"
>
Previous
</button>
<span>Page {{ page }} of {{ totalPages }}</span>
<button
@click="nextPage"
:disabled="page === totalPages || isLoading"
>
Next
</button>
</div>
<div v-if="isLoading">Loading...</div>
</div>
</template>Why `placeholderData` matters:
- Shows previous page data while next page loads
- Prevents layout shift / flashing
- Better UX than showing loader
---
Step 8: SSR and Nuxt Integration
Nuxt Auto-Configuration:
The @pinia/colada-nuxt module automatically:
- Installs and configures Pinia Colada
- Handles SSR serialization/hydration
- Auto-imports all composables
- Configures plugins for SSR compatibility
SSR Best Practices:
// 1. Use relative URLs for API calls (works in SSR + client)
const { data } = useQuery({
key: ['todos'],
query: () => fetch('/api/todos').then(r => r.json()),
})
// 2. Handle SSR/client differences
const { data } = useQuery({
key: ['todos'],
query: async () => {
// Use different URL in SSR vs client if needed
const baseURL = import.meta.server
? 'http://localhost:3000'
: ''
const response = await fetch(`${baseURL}/api/todos`)
return response.json()
},
})
// 3. Disable refetch on mount for SSR (data already fresh)
const { data } = useQuery({
key: ['todos'],
query: fetchTodos,
refetchOnMount: false, // SSR data is already fresh
})---
Official Documentation
- Pinia Colada: https://pinia-colada.esm.dev/
- GitHub Repository: https://github.com/posva/pinia-colada
- Nuxt Module: https://nuxt.com/modules/pinia-colada
- Pinia: https://pinia.vuejs.org/