Skip to content
TutorialPinia

Data fetching with Pinia Colada

Pinia Colada 0.21.0 was published on 6 January. It is not 1.0 yet, but the queries and mutations a todo list needs are close to stable.

vuejs.school team13 Jan 20266 min read@pinia/colada 0.21 · Pinia 3.0
cartitemstotaladd()refsRef<items>Ref<total>

Loading flags, error flags, a cache so two components do not fetch the same list twice, a refresh after every write: most apps write that code by hand, several times. Pinia Colada is a data-fetching layer built on Pinia, by Pinia's author, that owns that bookkeeping. By the end you will have a todo list that loads through a cached query, refreshes itself after every write and shares one filter between components, first in a Vite app and then in Nuxt.

The examples are a Vue 3.5 project created with npm create vue@latest with Pinia 3 selected, talking to a REST API at /api/todos. The documentation is the source for everything below.

Install and register the plugin

Pinia Colada needs Pinia and is registered after it, so that it can find the Pinia instance on its own:

npm install @pinia/colada
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, {
  queryOptions: {
    staleTime: 30_000, // data counts as fresh for 30 seconds
  },
})
app.mount('#app')

queryOptions sets defaults for every query. staleTime is how long a cached result counts as fresh (the default is 5 seconds); within that window a new component that asks for the same key gets the cached data and no request is made. The docs also name gcTime, the time an unused entry stays in memory, with a default of 300 000 milliseconds, five minutes.

The HTTP calls live in one module so that both the query and the mutation can import them. Throwing on a non-OK response matters: fetch() resolves even for a 404 or a 500, and Pinia Colada decides between success and error only by whether your function throws:

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

export type TodoFilter = 'all' | 'open'

export async function fetchTodos(filter: TodoFilter = 'all'): Promise<Todo[]> {
  const res = await fetch(`/api/todos?filter=${filter}`)
  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()
}

Load the list with useQuery()

A query is useQuery() with two required options: a key, an array that identifies the data in the cache, and a query, the function that loads it.

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

const { state, asyncStatus, refetch } = useQuery({
  key: ['todos'],
  query: () => fetchTodos(),
})
</script>

<template>
  <p v-if="asyncStatus === 'loading'">Refreshing…</p>
  <p v-if="state.status === 'pending'">Loading todos</p>
  <p v-else-if="state.status === 'error'">{{ state.error.message }}</p>
  <ul v-else>
    <li v-for="todo in state.data" :key="todo.id">{{ todo.title }}</li>
  </ul>
  <button type="button" @click="refetch()">Refresh</button>
</template>

Pinia Colada calls query with a context object of its own (an AbortSignal among other things), so the arrow function matters. Passing query: fetchTodos would hand that object to fetchTodos as its filter and request /api/todos?filter=[object Object]; TypeScript rejects it too.

The template reads two statuses that Pinia Colada keeps apart:

  • state.status describes the data: 'pending' until the first result ever arrives, then 'success' or 'error'.
  • asyncStatus describes the request: 'loading' while one is running, 'idle' otherwise.

A background refetch of a list you already have shows asyncStatus as 'loading' while state.status stays 'success', so the old list stays on screen with a small "Refreshing" note rather than being replaced by a spinner.

useQuery() also returns status, data and error as separate refs, but reading them through state lets TypeScript narrow the types: inside the 'error' branch state.error is not null, and in the final branch state.data is not undefined.

The button calls refetch(), which always makes the request, because a user who presses Refresh wants new data. refresh() reuses a running request and skips the call while the data is fresh, so a button wired to it would do nothing for 30 seconds after the list loads. Use refresh() when your code, not the user, asks for the data.

Add a todo and refresh the list

Writes go through useMutation(). It takes a mutation function, which receives the one value you pass to mutate(); when it needs several, pass an object. Unlike a query, it never runs on its own.

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

const queryCache = useQueryCache()
const title = ref('')

const { mutate, asyncStatus, state } = useMutation({
  mutation: (text: string) => createTodo(text),
  onSettled: () => queryCache.invalidateQueries({ key: ['todos'] }),
})

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

<template>
  <form @submit.prevent="submit">
    <input v-model="title" aria-label="New todo" />
    <button type="submit" :disabled="asyncStatus === 'loading'">Add</button>
  </form>
  <p v-if="state.status === 'error'">{{ state.error.message }}</p>
</template>

useQueryCache() returns the cache, which is a Pinia store underneath, and invalidateQueries({ key: ['todos'] }) marks every query whose key starts with ['todos'] as stale and refetches the ones a mounted component is using. That one line is what keeps the list in TodoList.vue honest without the two components knowing about each other. Keys are hierarchical, so ['todos', { filter: 'open' }] is invalidated by ['todos'] too; pass exact: true when you do not want that.

The hooks are onMutate, onSuccess, onError and onSettled. onSuccess receives the server's response, the variables you passed and a context object, which makes it the place to navigate to a freshly created item. onSettled runs after success and after failure, and the docs use it for invalidation: if the server rejected the write, or applied half of it, refetching is still the right move.

The arrow function in onSettled returns the invalidateQueries() promise, and Pinia Colada waits for it: the mutation's asyncStatus stays 'loading', and the Add button disabled, until the list has come back.

Put every input in the query key

Since query takes no arguments of yours, anything it reads from outside has to appear in the key. The mistake looks harmless:

src/components/TodoList.vueBreaks
const filter = ref<'all' | 'open'>('all')

const { state } = useQuery({
  key: ['todos'],
  query: () => fetchTodos(filter.value),
})

Switching the filter to 'open' changes nothing on screen: the key is still ['todos'], the cache already has fresh data for it, and no request is made. When one does happen later, the open list is written into the entry that every other component reads as the full list. The fix is a key that is a function of the same inputs:

src/components/TodoList.vueWorks
const filter = ref<'all' | 'open'>('all')

const { state } = useQuery({
  key: () => ['todos', { filter: filter.value }],
  query: () => fetchTodos(filter.value),
})

Now each filter has its own cache entry, the query re-runs when filter changes, and switching back to a filter you have already seen is instant. The docs put the rule in one sentence: make your key depend on every variable your query function uses.

Share a query and its state with defineQuery()

The filtered query above has a problem the moment a second component wants it. If you wrap it in an ordinary composable, each component gets its own filter ref, but the query is global and only ever sees the first one. defineQuery() exists for this. It turns the composable into a shared instance, created once, like a tiny Pinia store:

src/queries/todos.ts
import { ref } from 'vue'
import { defineQuery, useQuery } from '@pinia/colada'
import { fetchTodos, type TodoFilter } from '../api/todos'

export const useFilteredTodos = defineQuery(() => {
  const filter = ref<TodoFilter>('all')
  const { state, ...rest } = useQuery({
    key: () => ['todos', { filter: filter.value }],
    query: () => fetchTodos(filter.value),
  })
  return { ...rest, todos: state, filter }
})

Every component that calls useFilteredTodos() now shares the same filter, and a toggle in a toolbar changes the list in the main view.

For a query that takes a parameter which is not global, such as an id from props, the docs point to defineQueryOptions() with a key factory instead. And when a shared query reads a value that is not always there, such as a route param, the enabled option pauses it until the value exists, for example enabled: () => 'id' in route.params.

Set up Pinia Colada in Nuxt

In Nuxt, Pinia Colada ships as a module, @pinia/colada-nuxt, that sits on top of Pinia's own module. Nuxt 4 keeps components and pages under app/ and server routes under server/. Add the two modules with the CLI:

npx nuxi module add pinia
npm install @pinia/colada
npx nuxi module add @pinia/colada-nuxt

The module registers the plugin and auto-imports useQuery(), useMutation(), useQueryCache() and defineQuery(). Plugin options go in a colada.options.ts file typed with PiniaColadaOptions, at the project root next to nuxt.config.ts, not inside app/:

colada.options.ts
import type { PiniaColadaOptions } from '@pinia/colada'

export default {
  queryOptions: {
    staleTime: 30_000,
  },
} satisfies PiniaColadaOptions

Server rendering works without further setup: a query run on the server is serialised into the payload and picked up on the client. The docs flag three things that still need care in Nuxt:

  • Inside a defineQuery() callback, import useRoute from vue-router rather than using Nuxt's auto-imported one, or the query fires more often than it should when you navigate to or away from the page.
  • State created inside defineQuery(), like the filter ref above, is not sent in the payload. If the server may set it differently from the browser, use Nuxt's useState() instead of ref().
  • If your API throws custom error classes, they need a payload plugin with definePayloadReducer and definePayloadReviver to survive the trip from server to client.
Check it stuck3 questions from the Data fetching with Pinia Colada quiz.
Practice now
Was this helpful?

Keep reading