Appearance
Queries & Mutations (Pinia Colada)
This is the library-specific guide for Pinia Colada (@pinia/colada), our data-fetching layer for server state. The shared, library-agnostic rules — file and export naming, query/mutation structure, and the rationale — live in Queries & Mutations; this guide covers Pinia Colada's API and patterns.
Pinia Colada gives a declarative, predictable way to manage async state with built-in caching, stale-data handling, background refetching, and request deduplication. Like Pinia stores for client state, it is the single source of truth for server state — data that lives on the backend and can be invalidated or refetched at any time.
Queries
Each use<Entity>Query is a composable that wraps useQuery and returns a query instance — so the use prefix stays truthful (see 04.Composables) and consumers use it like any other composable. We wrap useQuery directly rather than the library-recommended defineQueryOptions, for use-prefix consistency and portability across data layers.
The Args type composes the library options with the query's custom params, and omits key/query so a caller cannot clobber the factory key via ...options (see the shared Args rule in Queries & Mutations):
ts
interface ArticleListQueryKeyArgs {
filters?: MaybeRefOrGetter<{ tag?: string; authorId?: number }>
}
type UseArticlesQueryArgs = Omit<Partial<UseQueryOptions<PaginatedArticleList>>, 'key' | 'query'> & ArticleListQueryKeyArgs
export function useArticlesQuery({ filters, ...options }: UseArticlesQueryArgs = {}) {
return useQuery({
key: () => articleKeys.list({ filters }), // getter — keeps the key reactive
query: () => fetchArticles(toValue(filters)),
...options,
})
}Default the Args to {} so useArticlesQuery() is valid out of the box.
Mutations
Cache Interaction
Use useQueryCache for invalidation:
ts
const queryCache = useQueryCache()
queryCache.invalidateQueries({ key: articleKeys.all })Usage in Components
- For checking the loading state, use the library-provided flags directly:
- Queries:
query.isLoading(aShallowRef<boolean>aliasingasyncStatus === 'loading'). - Mutations:
mutation.isLoading(aComputedRef<boolean>).
- Queries:
- Guard query data behind a computed that checks
query.isLoadingandquery.error.value(orquery.state.value.status === 'error') and provides safe defaults (empty arrays, nulls) before passing data into templates.
ts
const articles = computed(function () {
if (articlesQuery.isLoading.value || articlesQuery.error.value) {
return []
}
return articlesQuery.data.value ?? []
})- Instantiate the query composable once per setup and keep the instance intact (do not destructure). Pass
enabledto control when it runs (see the shared Disabling/Pausing rule in Queries & Mutations):
ts
import { computed } from 'vue'
import { useArticleQuery } from '@/queries/articles.query'
const articleQuery = useArticleQuery({
id: articleId,
enabled: computed(() => !!articleId.value),
})- Wrap user actions in small handlers that call
mutateAsyncinsidetry/catch, so UI feedback and side effects stay in the component. Reserve mutation callbacks (onSuccess,onError,onSettled) for non-visual side effects such as cache updates or logging (see the shared Error & Success Handling rule):
ts
async function handleCreateArticle(payload: { title: string; content: string }) {
try {
await createArticleMutation.mutateAsync(payload)
// user-facing feedback, e.g. toast or navigation
} catch (error) {
// user-facing error feedback
}
}- Remember to dereference reactive helpers with
.valueinside both script and template code; Pinia Colada returns computed/refs for each reactive flag.
Implementation example
ts
// src/queries/articles.query.ts
import { MaybeRefOrGetter, toValue } from 'vue'
import { useQuery, useMutation, useQueryCache, type UseQueryOptions } from '@pinia/colada'
import { fetchArticles, fetchArticle, createArticle } from '@/services/api/articles'
import type { Article, PaginatedArticleList } from '@/models/articles'
interface ArticleListQueryKeyArgs {
filters?: MaybeRefOrGetter<{ tag?: string; authorId?: number }>
}
interface ArticleItemQueryKeyArgs {
id: MaybeRefOrGetter<number>
}
export const articleKeys = {
all: ['content', 'articles'] as const,
list: ({ filters }: ArticleListQueryKeyArgs) => ['content', 'articles', 'list', toValue(filters)] as const,
item: ({ id }: ArticleItemQueryKeyArgs) => ['content', 'articles', 'item', toValue(id)] as const,
}
type UseArticlesQueryArgs = Omit<Partial<UseQueryOptions<PaginatedArticleList>>, 'key' | 'query'> & ArticleListQueryKeyArgs
type UseArticleQueryArgs = Omit<Partial<UseQueryOptions<Article>>, 'key' | 'query'> & ArticleItemQueryKeyArgs
export function useArticlesQuery({ filters, ...options }: UseArticlesQueryArgs = {}) {
return useQuery({
key: () => articleKeys.list({ filters }), // getter — keeps the key reactive
query: () => fetchArticles(toValue(filters)),
...options,
})
}
export function useArticleQuery({ id, ...options }: UseArticleQueryArgs = {}) {
return useQuery({
key: () => articleKeys.item({ id }),
query: () => fetchArticle(toValue(id)),
...options,
})
}
// Mutations
interface UseCreateArticleMutationArgs {
title: string
content: string
}
export function useCreateArticleMutation() {
const queryCache = useQueryCache()
return useMutation({
mutation: ({ title, content }: UseCreateArticleMutationArgs) =>
createArticle({ title, content }),
onSettled: () => {
queryCache.invalidateQueries({ key: articleKeys.all })
},
})
}Paginated Queries
Include page in the key and use placeholderData:
vue
<script setup lang="ts">
import { useQuery } from '@pinia/colada'
const contactsQuery = useQuery({
key: () => ['contacts', Number(route.query.page) || 1],
query: () => fetch(`/api/contacts?page=${Number(route.query.page) || 1}`).then(r => r.json()),
placeholderData: (previousData) => previousData,
})
</script>Optimistic Updates
Via Cache
ts
const createTodoMutation = useMutation({
mutation: (text: string) => createTodo(text),
onMutate: (text) => {
const oldTodos = queryCache.getQueryData(['todos'])
const newTodo = { id: crypto.randomUUID(), text }
queryCache.setQueryData(['todos'], [...(oldTodos || []), newTodo])
return { oldTodos, newTodo }
},
onError: (error, variables, { oldTodos }) => {
queryCache.setQueryData(['todos'], oldTodos)
},
onSettled: () => queryCache.invalidateQueries({ key: ['todos'] }),
})Via UI
vue
<script setup>
const createTodoMutation = useMutation({
mutation: (text: string) => createTodo(text),
onSettled: () => queryCache.invalidateQueries({ key: ['todos'] }),
})
</script>
<template>
<li v-if="createTodoMutation.isLoading.value" style="opacity: 0.5">{{ createTodoMutation.variables.value }}</li>
</template>Why this library differs
- Option names differ from TanStack:
key/query/mutation(vsqueryKey/queryFn/mutationFn). Thekeytakes a getter (key: () => …) so it stays reactive when inputs change. - Cache invalidation via
useQueryCache— a dedicated composable that providesinvalidateQueries,setQueryData, andgetQueryData. There is norefetchQueriesorremoveQueries; useinvalidateQueriesinstead. - Loading state is exposed as
query.isLoadingandmutation.isLoading(boolean ref/computed).asyncStatusis a two-state enum (idle/loading); errors surface viaquery.errorandstate.status('error'is aDataStateStatus, not anasyncStatusvalue). - Optimistic updates use
queryCache.setQueryDatawith rollback inonError, andplaceholderDatafor seamless pagination transitions. - Paginated queries use
placeholderData: (previousData) => previousDatato keep the previous page visible during refetch.
See the shared Rationale for the library-agnostic rationale.