Home / Blog / Smarter Data Fetching with Pinia Colada
Smarter Data Fetching with Pinia Colada

Smarter Data Fetching with Pinia Colada

Daniel Kelly
Daniel Kelly
September 23rd 2025

If you love Pinia but find yourself re-implementing cache, dedupe, loading/error states, optimistic updates, and other common async data needs, Pinia Colada is the missing layer that makes async state feel… effortless.

Why Pinia Colada?

Pinia Colada is a tiny, type-safe data-fetching layer built on top of Pinia. It gives you:

  • Automatic caching + request de-duplication
  • A declarative useQuery() for reads and useMutation() for writes
  • Hierarchical query keys for precise invalidation
  • Optimistic updates, global hooks, devtools, SSR/NUXT support, and a plugin system— all with ~2kb baseline and zero deps beyond Pinia.

To top it off, it comes with a first-rate devtools experience!

Pinia Colada Devtools

How to Install Pinia Colada

To install Pinia Colada, inside an existing Vue project run:

npm i @pinia/colada

Then register the plugin after pinia:

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
import { PiniaColada } from '@pinia/colada'

const app = createApp(App)
const pinia = createPinia()

app.use(pinia)
app.use(PiniaColada, {
  // Global defaults for queries (optional)
  queryOptions: { gcTime: 300_000 }, // 5 min
})

app.mount('#app')

Optionally, you can also install the devtools:

npm i -D @pinia/colada-devtools
<!-- App.vue -->
<script setup lang="ts">
import { PiniaColadaDevtools } from '@pinia/colada-devtools'
</script>

<template>
  <router-view />
  <PiniaColadaDevtools />
</template>

Queries 101: useQuery()

With Pinia Colada installed, you’re ready to start querying asynchronous state. A query is made up of two parts: a unique key and a function that returns a Promise. Pinia Colada triggers the query for you, caches the result, and dedupes concurrent calls.

<script setup lang="ts">
import { useQuery } from '@pinia/colada'

const { state, asyncStatus, refresh, refetch, error, data, isLoading } = useQuery({
  key: ['todos'],
  query: () => fetch('/api/todos').then(r => r.json()),
})
</script>

<template>
  <main>
    <div v-if="asyncStatus === 'loading'">Loading…</div>
    <div v-else-if="state.status === 'error'">Oops: {{ error?.message }}</div>
    <ul v-else>
      <li v-for="t in data" :key="t.id">{{ t.text }}</li>
    </ul>
  </main>
</template>

state.status tracks data lifecycle (pending → success/error) and asyncStatus tracks in-flight fetches—handy for nuanced UI.

The Pinia Colada useQuery Key

The useQuery key, is the key (pun intended) to the caching and deduplication logic. It works exactly like the key in a traditional caching system. If a value already exists for that key, (ie the query has already run and isn’t expired), then the next query to that same key will skip the run and return the cached value.

For example, if we run a query with the key ['todos'] on one PageA

<!-- PageA.vue-->
<script setup>
const { state} = useQuery({
  key: ['todos'],
  query: () => fetch('/api/todos').then(r => r.json()),
})
</script>

and then run the same query on PageB

<!-- PageB.vue-->
<script setup>
const { state} = useQuery({
  key: ['todos'],
  query: () => fetch('/api/todos').then(r => r.json()),
})
</script>

The fetch request will only be made once. 🎉

Dynamic Query Inputs? Put them in the Pinia Colada useQuery Key

This leads logically to the next point. Queries don’t accept parameters directly. Instead, close over reactive values (like route params) and reflect them in the key. This keeps the cache correct and enables partial invalidation later.

import { useRoute } from 'vue-router'
import { useQuery } from '@pinia/colada'

const route = useRoute()
const { data: contact } = useQuery({
  key: () => ['contacts', route.params.id as string],
  query: () => fetch(`/api/contacts/${route.params.id}`).then(r => r.json()),
})

Query Keys that Scale (and Save You Later)

But those keys are arrays, what’s up with that? It’s actually a super smart feature!

Pinia Colada’s keys are hierarchical arrays (strings, numbers, serializable objects). If two queries share a root, ie. ['products'] and ['products', id], you can invalidate them together.

queryCache.invalidateQueries({ key: ['products'] })

You can also define key factories to avoid typos and centralize structure.

// Define keys in a central location
// queries/products.ts
export const PRODUCT_KEYS = {
  root: ['products'] as const,
  byId: (id: string) => [...PRODUCT_KEYS.root, id] as const,
}

// Then use them in queries

const { state } = useQuery({
  key: () => PRODUCT_KEYS.root,
  query: () => ...
})

const { state } = useQuery({
  key: () => PRODUCT_KEYS.byId(route.params.id as string),
  query: () => ...
})

Mutations: Write Operations with useMutation() in Pinia Colada

useQuery is all about fetching data. What if you want to mutate or change that asynchronous state (typically via POST/PUT/PATCH/DELETE requests)? That’s what useMutation is for.

useMutateion exposes mutate and mutateAsync functions, plus lifecycle hooks such as onMutate, onError, and onSettled.

<script setup lang="ts">
import { ref } from 'vue'
import { useMutation } from '@pinia/colada'

const todoText = ref('')

const { mutate: createTodo, asyncStatus } = useMutation({
  mutation: (text: string) =>
    fetch('/api/todos', { method: 'POST', body: JSON.stringify({ text }) }),
})
</script>

<template>
  <form @submit.prevent="createTodo(todoText)">
    <input v-model="todoText" />
    <button :disabled="asyncStatus === 'loading'">Add todo</button>
  </form>
</template>

But what is the bit of extra ceremony for? No worries, it has a purpose! 👇

Keep the Cache Fresh: Invalidation

Mutations give you a standardized mechanism for invalidating cached queries. Active ones refetch immediately; inactive ones become stale and refetch when used again.

import { useMutation, useQueryCache } from '@pinia/colada'
const cache = useQueryCache()

const { mutate: createTodo } = useMutation({
  mutation: (text: string) => createTodoAPI(text),
  onSettled: () => cache.invalidateQueries({ key: ['todos'], exact: true }),
})

Ultra-Fast UX: Optimistic Updates

Beyond caching, we can also make optimistic UI updates. This means we update the UI first on a mutation; and then roll back on error of the asynchronous request (the exception). This makes UIs that feel blazing fast.

Pinia Colada gives you everything to set/get/cancel cache entries inside mutation hooks.

import { useMutation, useQueryCache } from '@pinia/colada'
const cache = useQueryCache()

const { mutate: addTodo } = useMutation({
  mutation: (text: string) => createTodoAPI(text),

  onMutate(text: string) {
    const prev = cache.getQueryData<string[]>(['todos']) || []
    const optimistic = [...prev, text]
    cache.setQueryData(['todos'], optimistic)
    cache.cancelQueries({ key: ['todos'] }) // prevent stale overwrites
    return { prev }
  },

  onError(_err, _vars, ctx) {
    // rollback if nothing else changed the cache
    if (ctx?.prev) cache.setQueryData(['todos'], ctx.prev)
  },

  onSettled() {
    cache.invalidateQueries({ key: ['todos'] })
  },
})

Pinia Colada Is SSR Safe by Design (It works in Nuxt!)

Pinia Colada supports SSR out of the box and ships a Nuxt module for plug-and-play integration:

npm i @pinia/colada
npx nuxi module add @pinia/colada-nuxt
npx nuxi module add pinia
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@pinia/colada-nuxt', 'pinia'],
})

You can also set optional project-wide options in colada.options.ts

// colada.options.ts
import type { PiniaColadaOptions } from '@pinia/colada'
export default { 
    /* queryOptions, plugins, etc. */ 
} satisfies PiniaColadaOptions

Nuxt module notes & SSR specifics (including error serialization) are in the docs.

Prefer lazy queries on the server when you don’t want a fetch to block SSR:

Organizing Pinia Colada at Scale: defineQueryOptions() & defineQuery()

Our examples so far, have called useQuery directly in side of components. This is great for understanding but doesn’t scale well in the real world.

Therefore the Pinia docs recommend you colocate query options & key factories in a /queries/ directory and reuse them everywhere:

// queries/todos.ts
import { defineQueryOptions } from '@pinia/colada'
import { getTodotById } from '@/api/todos'

export const TODO_KEYS = {
  root: ['todos'] as const,
  byId: (id: string) => [...TODO_KEYS.root, id] as const,
}

export const documentById = defineQueryOptions(({ id }: { id: string }) => ({
  key: TODO_KEYS.byId(id),
  query: () => getTodotById(id),
}))

// Then inside a component
// MyComponent.vue
import { documentById } from '@/queries/todos'
const { data: todos } = useQuery(
    documentById, 
    () => ({ id: route.params.docId as string })
)

Final Thoughts about Pinia Colada

Pinia Colada lets you keep using the store you already know and love while layering in a modern data-fetching engine with support for cache, dedupe, optimistic updates, and more. It’s tiny, type-safe, and scales with your app’s complexity.

Start learning Vue.js for free

Daniel Kelly
Daniel Kelly
Daniel is the lead instructor at Vue School and enjoys helping other developers reach their full potential. He has 10+ years of developer experience using technologies including Vue.js, Nuxt.js, and Laravel.

Comments

Latest Vue School Articles

How to Copy to Clipboard In Vue

How to Copy to Clipboard In Vue

How to copy to clipboard in Vue & Nuxt: use VueUse’s useClipboard, build a v-copy directive and handle native fallbacks.
Daniel Kelly
Daniel Kelly
State Based Favicons (Tab Alerts) in Vue

State Based Favicons (Tab Alerts) in Vue

Add state-based favicons in Vue to show unread counts, alerts, or status dots with VueUse for subtle, polished tab notifications.
Daniel Kelly
Daniel Kelly
VueSchool logo

Our goal is to be the number one source of Vue.js knowledge for all skill levels. We offer the knowledge of our industry leaders through awesome video courses for a ridiculously low price.

More than 200.000 users have already joined us. You are welcome too!

Follow us on Social

© All rights reserved. Made with ❤️ by BitterBrains, Inc.