Skip to content

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 (a ShallowRef<boolean> aliasing asyncStatus === 'loading').
    • Mutations: mutation.isLoading (a ComputedRef<boolean>).
  • Guard query data behind a computed that checks query.isLoading and query.error.value (or query.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 enabled to 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 mutateAsync inside try/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 .value inside 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 (vs queryKey/queryFn/mutationFn). The key takes a getter (key: () => …) so it stays reactive when inputs change.
  • Cache invalidation via useQueryCache — a dedicated composable that provides invalidateQueries, setQueryData, and getQueryData. There is no refetchQueries or removeQueries; use invalidateQueries instead.
  • Loading state is exposed as query.isLoading and mutation.isLoading (boolean ref/computed). asyncStatus is a two-state enum (idle / loading); errors surface via query.error and state.status ('error' is a DataStateStatus, not an asyncStatus value).
  • Optimistic updates use queryCache.setQueryData with rollback in onError, and placeholderData for seamless pagination transitions.
  • Paginated queries use placeholderData: (previousData) => previousData to keep the previous page visible during refetch.

See the shared Rationale for the library-agnostic rationale.