---
title: "Data fetching with Pinia Colada · vuejs.school"
description: "Build a todo list on Pinia Colada 0.21: a cached query, status against asyncStatus, a mutation that invalidates the list, defineQuery and the Nuxt module."
canonical_url: "https://vuejs.school/community/blog/data-fetching-with-pinia-colada"
meta:
  "og:description": "Build a todo list on Pinia Colada 0.21: a cached query, status against asyncStatus, a mutation that invalidates the list, defineQuery and the Nuxt module."
  "og:image": "https://vuejs.school/_og/d/a_~RGF0YSBmZXRjaGluZyB3aXRoIFBpbmlhIENvbGFkYSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_Data%2Bfetching%2Bwith%2BPinia%2BColada%2Cdescription_~QnVpbGQgYSB0b2RvIGxpc3Qgb24gUGluaWEgQ29sYWRhIDAuMjE6IGEgY2FjaGVkIHF1ZXJ5LCBzdGF0dXMgYWdhaW5zdCBhc3luY1N0YXR1cywgYSBtdXRhdGlvbiB0aGF0IGludmFsaWRhdGVzIHRoZSBsaXN0LCBkZWZpbmVRdWVyeSBhbmQgdGhlIE51eHQgbW9kdWxlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9kYXRhLWZldGNoaW5nLXdpdGgtcGluaWEtY29sYWRhIg%2Cs_k0KWzQe0kg08ryfi.png"
  "og:image:alt": "Data fetching with Pinia Colada · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Data fetching with Pinia Colada · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~RGF0YSBmZXRjaGluZyB3aXRoIFBpbmlhIENvbGFkYSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_Data%2Bfetching%2Bwith%2BPinia%2BColada%2Cdescription_~QnVpbGQgYSB0b2RvIGxpc3Qgb24gUGluaWEgQ29sYWRhIDAuMjE6IGEgY2FjaGVkIHF1ZXJ5LCBzdGF0dXMgYWdhaW5zdCBhc3luY1N0YXR1cywgYSBtdXRhdGlvbiB0aGF0IGludmFsaWRhdGVzIHRoZSBsaXN0LCBkZWZpbmVRdWVyeSBhbmQgdGhlIE51eHQgbW9kdWxlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9kYXRhLWZldGNoaW5nLXdpdGgtcGluaWEtY29sYWRhIg%2Cs_k0KWzQe0kg08ryfi.png"
  "twitter:image:alt": "Data fetching with Pinia Colada · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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.vue**Breaks***

```
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.vue**Works***

```
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 stuck**3 questions from the Data fetching with Pinia Colada quiz.

[**Practice now**](https://vuejs.school/learn/pinia/data-fetching-with-pinia-colada?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 6 min read**Upgrading a Pinia 3 project to Pinia 4, with the Nuxt and testing packages**](https://vuejs.school/community/blog/upgrading-a-pinia-3-project-to-pinia-4) [Release notes · 7 min read**Pinia 4: what changed and what to do**](https://vuejs.school/community/blog/pinia-4-what-changed-and-what-to-do)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/data-fetching-with-pinia-colada#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-01-13","description":"Build a todo list on Pinia Colada 0.21: a cached query, status against asyncStatus, a mutation that invalidates the list, defineQuery and the Nuxt module.","name":"Data fetching with Pinia Colada","url":"https://vuejs.school/community/blog/data-fetching-with-pinia-colada","breadcrumb":{"@id":"https://vuejs.school/community/blog/data-fetching-with-pinia-colada#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/data-fetching-with-pinia-colada"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/data-fetching-with-pinia-colada#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-01-13","description":"Build a todo list on Pinia Colada 0.21: a cached query, status against asyncStatus, a mutation that invalidates the list, defineQuery and the Nuxt module.","headline":"Data fetching with Pinia Colada","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~RGF0YSBmZXRjaGluZyB3aXRoIFBpbmlhIENvbGFkYSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_Data%2Bfetching%2Bwith%2BPinia%2BColada%2Cdescription_~QnVpbGQgYSB0b2RvIGxpc3Qgb24gUGluaWEgQ29sYWRhIDAuMjE6IGEgY2FjaGVkIHF1ZXJ5LCBzdGF0dXMgYWdhaW5zdCBhc3luY1N0YXR1cywgYSBtdXRhdGlvbiB0aGF0IGludmFsaWRhdGVzIHRoZSBsaXN0LCBkZWZpbmVRdWVyeSBhbmQgdGhlIE51eHQgbW9kdWxlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9kYXRhLWZldGNoaW5nLXdpdGgtcGluaWEtY29sYWRhIg%2Cs_k0KWzQe0kg08ryfi.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/data-fetching-with-pinia-colada#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/data-fetching-with-pinia-colada#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/data-fetching-with-pinia-colada#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Data fetching with Pinia Colada","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~RGF0YSBmZXRjaGluZyB3aXRoIFBpbmlhIENvbGFkYSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_Data%2Bfetching%2Bwith%2BPinia%2BColada%2Cdescription_~QnVpbGQgYSB0b2RvIGxpc3Qgb24gUGluaWEgQ29sYWRhIDAuMjE6IGEgY2FjaGVkIHF1ZXJ5LCBzdGF0dXMgYWdhaW5zdCBhc3luY1N0YXR1cywgYSBtdXRhdGlvbiB0aGF0IGludmFsaWRhdGVzIHRoZSBsaXN0LCBkZWZpbmVRdWVyeSBhbmQgdGhlIE51eHQgbW9kdWxlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9kYXRhLWZldGNoaW5nLXdpdGgtcGluaWEtY29sYWRhIg%2Cs_k0KWzQe0kg08ryfi.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~RGF0YSBmZXRjaGluZyB3aXRoIFBpbmlhIENvbGFkYSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_Data%2Bfetching%2Bwith%2BPinia%2BColada%2Cdescription_~QnVpbGQgYSB0b2RvIGxpc3Qgb24gUGluaWEgQ29sYWRhIDAuMjE6IGEgY2FjaGVkIHF1ZXJ5LCBzdGF0dXMgYWdhaW5zdCBhc3luY1N0YXR1cywgYSBtdXRhdGlvbiB0aGF0IGludmFsaWRhdGVzIHRoZSBsaXN0LCBkZWZpbmVRdWVyeSBhbmQgdGhlIE51eHQgbW9kdWxlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9kYXRhLWZldGNoaW5nLXdpdGgtcGluaWEtY29sYWRhIg%2Cs_k0KWzQe0kg08ryfi.png"}]}
```