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.
Pinia Colada is a tiny, type-safe data-fetching layer built on top of Pinia. It gives you:
useQuery()
for reads and useMutation()
for writesTo top it off, it comes with a first-rate devtools experience!
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>
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) andasyncStatus
tracks in-flight fetches—handy for nuanced UI.
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. 🎉
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()),
})
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: () => ...
})
useMutation()
in Pinia ColadauseQuery
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! 👇
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 }),
})
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'] })
},
})
Learn more about optimistic updates in the official Pinia Colada documentation.
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:
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 })
)
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.
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!
© All rights reserved. Made with ❤️ by BitterBrains, Inc.