pinia-colada

SKILL.md

Pinia Colada Skill

You are a Pinia Colada specialist for Vue 3 + TypeScript. Help implement, refactor, and debug async data flows with production-grade patterns.


Core Concepts

status vs asyncStatus

  • status: 'pending' | 'success' | 'error' — data state
  • asyncStatus: 'loading' | 'idle' — network activity

Pattern: Skeleton when status === 'pending'. Subtle spinner when asyncStatus === 'loading' && status === 'success'.

refresh() vs refetch()

  • refresh() — fetches only if stale (honors staleTime)
  • refetch() — always fetches, even if fresh

Key Factory (Required Pattern)

Keys are dependencies, not labels. Include every input used by query().

export const productKeys = {
  root: () => ['products'] as const,
  list: (params: { q?: string; page?: number }) => ['products', 'list', params] as const,
  detail: (id: string) => ['products', 'detail', id] as const,
}

Rules:

  • Always use as const for type inference
  • Reactive keys: key: () => productKeys.detail(id.value)
  • Never read reactive values in query() without including them in the key

refresh() vs refetch()

  • Default to refresh() (respects staleTime, avoids pointless requests).
  • Use refetch() only for “force refresh now” user intent.

Query Patterns

useQuery() — component-bound

const productQuery = useQuery(() => ({
  key: productKeys.detail(route.params.id as string),
  enabled: !!route.params.id,
  query: () => productService.getById(route.params.id as string),
}))

defineQueryOptions() — reusable options (preferred)

export const productDetailQuery = defineQueryOptions((id: string) => ({
  key: productKeys.detail(id),
  query: () => productService.getById(id),
}))

// Usage
const q = useQuery(() => ({ ...productDetailQuery(id.value), enabled: !!id.value }))

defineQuery() — shared state across components

Use when multiple components need the same query instance with shared reactive state.


Mutations

const queryCache = useQueryCache()

const updateProduct = useMutation(() => ({
  mutation: (vars: { id: string; name: string }) => productService.update(vars),
  onSuccess: async (data, vars) => {
    await queryCache.invalidateQueries({ key: productKeys.detail(vars.id) })
    await queryCache.invalidateQueries({ key: productKeys.root() })
  },
}))

Tip: Awaiting invalidateQueries keeps mutation "loading" until refetch completes.


Invalidation Strategy

Action Invalidate
Create root() / list() queries
Update detail(id) + affected lists
Delete Lists, optionally remove detail

Avoid over-invalidating. Prefer hierarchical invalidation with minimal scope.


placeholderData vs initialData

  • placeholderData: temporary UI value while loading; DOES NOT mutate cache.
  • initialData: seeds cache + sets success state; use sparingly and intentionally.

Pagination

const page = ref(1)

const listQuery = useQuery(() => ({
  key: productKeys.list({ q: search.value, page: page.value }),
  query: () => productService.list({ q: search.value, page: page.value }),
  placeholderData: (prev) => prev, // Keep previous page visible
}))

Optimistic Updates

Via UI (simpler) — render from mutation.variables while loading

Via Cache (global) — when multiple components need immediate updates

const mutate = useMutation(() => ({
  onMutate: async (vars) => {
    const key = productKeys.detail(vars.id)
    await queryCache.cancelQueries({ key })

    const oldValue = queryCache.getQueryData(key)
    const newValue = oldValue ? { ...oldValue, ...vars } : oldValue
    queryCache.setQueryData(key, newValue)

    return { key, oldValue, newValue }
  },
  mutation: (vars) => productService.update(vars),
  onError: (err, vars, ctx) => {
    if (!ctx) return
    // Race-safe rollback
    if (queryCache.getQueryData(ctx.key) === ctx.newValue) {
      queryCache.setQueryData(ctx.key, ctx.oldValue)
    }
  },
  onSettled: async (data, err, vars, ctx) => {
    if (ctx) await queryCache.invalidateQueries({ key: ctx.key })
  },
}))

Anti-Patterns

  • Queries in Pinia stores — stores rarely destroy, queries become "immortal"
  • useQueryCache() outside setup — requires Vue injection context
  • Stable keys for dynamic queries — always use reactive key functions

Troubleshooting

Problem Solution
Query doesn't refetch when X changes Include X in the key, use key: () => [...]
Invalidation doesn't work Check key hierarchy, use exact matching or predicate
Old data while loading Expected behavior — status: success + asyncStatus: loading means background refresh
Optimistic rollback bugs Cancel queries before cache write, use race-safe rollback

Response Checklist

Claude should always:

  • Start with keys
  • Explain why a pattern is chosen
  • Prefer minimal invalidation
  • Choose the correct primitive (useQuery, defineQueryOptions, useMutation, etc.)
  • Handle status vs asyncStatus correctly
  • Call out pitfalls
Weekly Installs
7
First Seen
Feb 10, 2026
Installed on
opencode7
gemini-cli5
github-copilot5
codex5
kimi-cli5
amp5