Skip to content
TutorialData & APIs

TanStack Query or Pinia Colada: caching your server data

Two libraries, one job: keep the data your components fetch in a shared cache, deduplicate requests and refetch after a write. Here is the same todo list in @tanstack/vue-query and in @pinia/colada.

vuejs.school team13 Nov 20256 min read@tanstack/vue-query 5.90 · @pinia/colada 0.17

Fetch a list in onMounted, keep it in a ref, show a spinner while it loads. That works for one component. Then a second component needs the same list, the user navigates away and back, and a form adds an item that the list does not show until a reload. By the end of this post you will have built the same todo list twice, once with TanStack Query and once with Pinia Colada, and you will have a rule for which one to install.

The examples are a plain Vue 3.5 project created with npm create vue@latest, talking to a small REST API at /api/todos. Nothing here needs Nuxt.

What a query library does

A query library is a cache for server data, keyed by what the data is. You give it a key such as ['todos'] and a function that fetches, and it does four jobs you would otherwise write by hand.

  • It caches. The second component that asks for ['todos'] gets the data that is already there, with no request.
  • It deduplicates. Two components mounting at the same moment produce one request, not two.
  • It tracks freshness. Data becomes stale after a time you choose. Stale data is refetched in the background when a component mounts or the window regains focus, and the old data stays on screen while that happens.
  • It reports status. Pending, error and success are flags you read, not booleans you flip.

The last piece is writes. Both libraries call a write a mutation, and both give you a way to say "this list is out of date, fetch it again" once a mutation finishes. That step is called invalidation, and it is the part hand-written code most often forgets.

Put the fetch calls in one module

Both versions of the list import these two functions, so the components never call fetch themselves.

src/api/todos.ts
export interface Todo {
  id: number
  title: string
  done: boolean
}

export async function fetchTodos(): Promise<Todo[]> {
  const res = await fetch('/api/todos')
  if (!res.ok) throw new Error(`Loading todos failed with ${res.status}`)
  return res.json()
}

export async function createTodo(title: string): Promise<Todo> {
  const res = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title }),
  })
  if (!res.ok) throw new Error(`Creating a todo failed with ${res.status}`)
  return res.json()
}

Throwing on a non-OK response matters. fetch only rejects on a network failure, and both libraries decide between success and error by whether your function throws.

Build the list with TanStack Query

@tanstack/vue-query is the Vue adapter of TanStack Query, the library many developers know from React. Version 5 has been the current major since October 2023, and the Vue and React adapters share one core package, @tanstack/query-core.

npm install @tanstack/vue-query

Register the plugin once. It creates the query client, the object that owns the cache.

src/main.ts
import { createApp } from 'vue'
import { VueQueryPlugin } from '@tanstack/vue-query'
import App from './App.vue'

createApp(App).use(VueQueryPlugin).mount('#app')

The component reads with useQuery and writes with useMutation.

src/components/TodoList.vue
<script setup lang="ts">
import { ref } from 'vue'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { createTodo, fetchTodos } from '../api/todos'

const queryClient = useQueryClient()

const { data: todos, isPending, isError, error } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 30_000,
})

const title = ref('')
const { mutate: addTodo, isPending: isSaving } = useMutation({
  mutationFn: createTodo,
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})

function submit() {
  if (!title.value.trim()) return
  addTodo(title.value)
  title.value = ''
}
</script>

<template>
  <form @submit.prevent="submit">
    <input v-model="title" aria-label="New todo" />
    <button type="submit" :disabled="isSaving">Add</button>
  </form>
  <p v-if="isPending">Loading todos</p>
  <p v-else-if="isError">{{ error?.message }}</p>
  <ul v-else>
    <li v-for="todo in todos" :key="todo.id">{{ todo.title }}</li>
  </ul>
</template>

queryKey is an array, and it is the identity of the data: any component that uses ['todos'] shares this cache entry. data is a ref that starts as undefined, isPending is true until the first result arrives, and isError with error cover the failure case. A failed request is retried three times by default, with growing waits, so the error message appears about seven seconds after the first failure.

staleTime: 30_000 says the list counts as fresh for thirty seconds. The default is zero, which means every new mount refetches in the background while showing what it has.

The mutation's onSuccess calls invalidateQueries with the same key. Invalidation marks the cached list stale and refetches it straight away wherever it is on screen. Keys match by prefix, so invalidating ['todos'] also refreshes ['todos', { page: 2 }]. Pass exact: true when you do not want that.

Build the list with Pinia Colada

Pinia Colada is a data fetching layer built on top of Pinia, by the author of Pinia. The cache is itself a Pinia store, so it appears in Vue DevTools next to your own stores. The library is small: its README puts the baseline at about 2 kB.

npm install pinia @pinia/colada

Its package.json asks for Vue 3.5.17 or newer and Pinia 2.2.6 or newer, Pinia 3 included. Then register both plugins in src/main.ts.

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)
app.use(createPinia())
app.use(PiniaColada)
app.mount('#app')

The order matters: Pinia first, then Pinia Colada. Installed the other way round, Pinia Colada throws "root pinia plugin not detected" in development.

The component's script changes, and its template keeps every line but one.

src/components/TodoList.vue
<script setup lang="ts">
import { ref } from 'vue'
import { useMutation, useQuery, useQueryCache } from '@pinia/colada'
import { createTodo, fetchTodos } from '../api/todos'

const queryCache = useQueryCache()

const { data: todos, isPending, error } = useQuery({
  key: ['todos'],
  query: fetchTodos,
  staleTime: 30_000,
})

const title = ref('')
const { mutate: addTodo, isLoading: isSaving } = useMutation({
  mutation: createTodo,
  onSettled: () => queryCache.invalidateQueries({ key: ['todos'] }),
})

function submit() {
  if (!title.value.trim()) return
  addTodo(title.value)
  title.value = ''
}
</script>

Compared with the TanStack version, four things change:

  • Shorter names. key and query instead of queryKey and queryFn, mutation instead of mutationFn, and useQueryCache() instead of useQueryClient().
  • No isError. In the template, change v-else-if="isError" to v-else-if="error". If you keep the old line, Vue warns that isError is not defined, and a failed load shows an empty list instead of the message.
  • Two statuses. status is 'pending', 'success' or 'error' and describes the data, while asyncStatus is 'idle' or 'loading' and describes the request. So isPending means "no data yet" and isLoading means "a request is in flight": a background refetch of stale data shows isLoading true with isPending false. A mutation exposes isLoading the same way.
  • Different defaults. staleTime defaults to five seconds, where TanStack's default is zero. A failed request is not retried, so the error shows at once; retries come from a separate plugin, @pinia/colada-plugin-retry.

This version also invalidates in onSettled rather than onSuccess (both libraries have both hooks). onSettled runs after success or failure, so the list is refreshed even when the server rejected the write. The migration guide from TanStack Vue Query lists more differences.

Keys can be functions. key: () => ['todos', route.params.id as string], with route from Vue Router's useRoute(), gives each id its own cache entry and refetches when the param changes. The as string is for TypeScript: a key holds only JSON values, and create-vue's strict settings type the param as possibly undefined.

To ask for fresh data from code, Pinia Colada offers refresh(), which deduplicates and respects staleTime, and refetch(), which always hits the network.

Fix a list that does not update after a write

Add a header component that shows a count with useQuery({ queryKey: ['todos'], queryFn: fetchTodos }). Then write the mutation like this.

src/components/TodoList.vueBreaks
const { mutate: addTodo } = useMutation({
  mutationFn: createTodo,
})

The request succeeds. The server has the new todo. The list in this component and the count in the header both keep showing the cached array, because nothing told the cache that ['todos'] changed. Reaching into todos.value and pushing the new item does not fix it: data is read-only in TanStack Query, and in both libraries the next refetch would overwrite your edit anyway.

src/components/TodoList.vueWorks
const queryClient = useQueryClient()

const { mutate: addTodo } = useMutation({
  mutationFn: createTodo,
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})

One line, and every component that reads ['todos'] updates, including ones you have not written yet. In Pinia Colada the same line is onSettled: () => queryCache.invalidateQueries({ key: ['todos'] }). After every write, invalidate the key you changed.

Which one to install

Install TanStack Query if you are shipping to users this quarter and the team wants an API that will look the same next year. Version 5 is two years old, the documentation is thorough, there is a devtools panel in @tanstack/vue-query-devtools, and almost any question has already been answered somewhere. The cost is a second mental model next to Pinia, with its own client and its own devtools.

Install Pinia Colada if your app already lives in Pinia and you are willing to follow a moving API. Everything sits in one place: the query cache is a store, useQueryCache() works inside your other stores, and the whole thing is smaller to learn. Expect to touch option names between now and a stable release.

Check it stuck3 questions from the Query libraries, TanStack Query and Pinia Colada quiz.
Practice now
Was this helpful?

Keep reading