Skip to content

Quick Start

Pinia Colada is the perfect companion to Pinia to handle async state management in your Vue applications. It will remove the need to write boilerplate code for data fetching and transparently bring caching, deduplication, invalidation, stale while revalidate (swr) and much more. Allowing you to focus on building the best user experience for your users. You don't even need to learn Pinia to use Pinia Colada because it exposes its own composables.

Get a quick overview of how to use Pinia Colada in your project or if you prefer to directly play with the code you can play with Pinia Colada in this Stackblitz project.

Installation

Install Pinia Colada alongside Pinia using your favorite package manager:

bash
npm i @pinia/colada

Setup

Install the PiniaColada plugin after Pinia (so it picks it up automatically ✨). This allows you to provide global options for your queries as well as plugins:

ts
import { 
createApp
} from 'vue'
import {
createPinia
} from 'pinia'
import {
PiniaColada
} from '@pinia/colada'
const
app
=
createApp
({})
app
.
use
(
createPinia
())
app
.
use
(
PiniaColada
, {
queryOptions
: {
// change the stale time for all queries to 0ms
staleTime
: 0,
},
mutationOptions
: {
// add global mutation options here },
plugins
: [
// add Pinia Colada plugins here ], })
app
.
mount
('#app')

Usage

Let's see a quick example and cover the basics of queries, mutations and query invalidation.

Querying

Queries are the most important feature of Pinia Colada. They are used to declaratively fetch data from an API. Create them with useQuery() in any component. They expect a key to save the data in the cache and a param-less query function that returns the data:

vue
<script setup lang="ts">
import { 
useQuery
} from '@pinia/colada'
import {
getAllProducts
} from '@/api/products'
import
ProductItem
from '@/components/ProductItem.vue'
const {
state
:
productList
,
asyncStatus
,
refresh
,
} =
useQuery
({
key
: ['products-list'],
query
:
getAllProducts
,
}) </script>
  • The key is a serializable array that uniquely identifies the query. The array allows to establish a hierarchy of keys that can be invalidated at once.
  • The query function takes no arguments because that allows Pinia Colada to automatically run it when needed ✨.

useQuery() returns an object with quite a few properties. In the example above we use:

  • state: state of the query. It contains the following properties:

    • data: the last data returned by the query. It automatically updates when the query is refetched.

    • error: the error thrown by the query if it failed, null otherwise.

    • status: the data status of the query. It starts as 'pending', and then it changes to 'success' or 'error' depending on the outcome of the query function:

      statusdataerror
      'pending'undefinednull
      'success'definednull
      'error'undefined or defineddefined
  • asyncStatus: the async status of the query. It's either 'idle' or 'loading' if the query is currently being fetched.

  • refresh(): manually triggers the query and conditionally runs it depending on if the cache is fresh or stale.

  • refetch(): manually force triggers the query, ignoring if the cache is fresh or stale.

There are a couple of other properties that can be accessed, most of them are just for convenience like data (which is an alias for state.data) and error (which is an alias for state.error).

Let's see a more complete example with a list view and a detail view:

vue
<script setup lang="ts">
import { 
useQuery
} from '@pinia/colada'
import {
getAllProducts
} from '@/api/products'
import
ProductItem
from '@/components/ProductItem.vue'
const { // when using multiple queries in the same component, // it is convenient to rename `data`
state
:
productList
,
asyncStatus
,
} =
useQuery
({
key
: ['products-list'],
query
:
getAllProducts
,
}) </script> <template> <
main
>
<LoadingIndicator v-if="
asyncStatus
=== 'loading'" />
<
div
v-if="
productList
.
error
">
<ErrorMessage
:
error
="
productList
.
error
" />
</
div
>
<
div
v-else-if="
productList
.
data
">
<
div
v-for="
product
in
productList
.
data
"
:
key
="
product
.
id
">
<!-- we could add a prefetch here : @mouseover="prefetch(product.id)" --> <ProductItem
:
product
="
product
" />
</
div
>
</
div
>
</
main
>
</template>
vue
<script setup lang="ts">
import { 
useQuery
} from '@pinia/colada'
import {
useRoute
} from 'vue-router'
import {
getProductById
} from '@/api/products'
import
ProductItemDetail
from '@/components/ProductItemDetail.vue'
// in this example we use the url params in the query to fetch // a specific product const
route
=
useRoute
()
const {
state
:
product
,
asyncStatus
} =
useQuery
({
// `key` can be made dynamic by providing a function or a reactive property
key
: () => ['products',
route
.
params
.
id
as string],
query
: () =>
getProductById
(
route
.
params
.
id
as string),
}) </script> <template> <
main
>
<LoadingIndicator v-if="
asyncStatus
=== 'loading'" />
<
div
v-if="
product
.
error
">
<ErrorMessage
:
error
="
product
.
error
" />
</
div
>
<
div
v-else
class
="flex flex-wrap">
<ProductItemDetail
:
product
="
product
.
data
" />
</
div
>
</
main
>
</template>

In this example, we have two pages: one that lists all products and another that shows the details of a specific product. Both pages use useQuery() to fetch the data they need. The query in the detail page uses the route params to fetch the specific product.

Mutating data

Mutations allow us to modify data on the server and notify related queries so they can automatically refetch the data. Create mutations with useMutation() in any component. Unlike queries, mutations can take one parameter and only require a mutation function, the key is optional:

vue
<script setup lang="ts">
import { 
useMutation
} from '@pinia/colada'
import {
patchContact
, type Contact } from '@/api/contacts'
import
ContactDetail
from '@/components/ContactDetail.vue'
const {
mutate
,
state
,
asyncStatus
} =
useMutation
({
mutation
: (
contact
: Contact) =>
patchContact
(
contact
),
}) </script>

Mutations are never called automatically, you have to call them manually with mutate. This allows you to pass one single argument to the mutation function. In general, prefer explicitly passing data to mutations instead of relying on the component's state, this will make everything so much better 😉.

useMutation() returns a very similar object to useQuery(), it's mostly about the mutation async state. Instead of refresh() we have mutate().

Let's see a more complete example with a mutation to update a contact and to invalidate other queries.

vue
<script setup lang="ts">
import { 
useMutation
,
useQueryCache
} from '@pinia/colada'
import {
patchContact
} from '@/api/contacts'
import type { Contact } from '@/api/contacts' import
ContactDetail
from '@/components/ContactDetail.vue'
const
props
=
defineProps
<{
contact
: Contact }>()
// we use the query cache to invalidate queries const
queryCache
=
useQueryCache
()
const {
mutate
:
updateContact
,
state
,
asyncStatus
,
} =
useMutation
({
mutation
: (
contact
: Contact) =>
patchContact
(
contact
),
onSettled
(
updatedContact
,
error
,
contact
) {
// invalidate the contact list and detail queries // they will be refetched automatically if they are being used
queryCache
.
invalidateQueries
({
key
: ['contacts-list'] })
queryCache
.
invalidateQueries
({
key
: ['contacts',
contact
.
id
] })
}, }) </script> <template> <
main
>
<LoadingIndicator v-if="
asyncStatus
=== 'loading'" />
<
div
v-if="
state
.
error
">
<ErrorMessage
:
error
="
state
.
error
" />
</
div
>
<
div
v-else>
<ContactDetail
:
contact
="
props
.
contact
"
:
is-updating
="
asyncStatus
=== 'loading'"
@
update
="
updateContact
"
/> </
div
>
</
main
>
</template>

By using the onSettled() hook, we can invalidate queries when the mutation is done. We get access to the data returned by the patchContact(query), the error if it failed, and the contact that was passed to the mutate() call. Then we use the queryCache to invalidate the queries we want. Invalidated queries are automatically refetched if they are actively being used ✨.

Scaling up

The examples above show simple use cases where we manually pass query options such as key in multiple places. As your application grows, you might want to reuse query definitions across multiple components. Pinia Colada provides a way to define reusable query options with defineQueryOptions().

This approach offers several key advantages:

  • Type Safety: Passing the key to the queryCache makes it fully typed
  • Reusability: Avoid typos in keys, reuse the options across your app
  • Refactoring: Update query logic in one place, changes propagate everywhere

On top of using defineQueryOptions(), we can also organize the query keys in an object to avoid typos and have a single source of truth for our keys. We call these key factories:

ts
export const 
PRODUCT_QUERY_KEYS
= {
// note the use of `as const` to ensure a strict tuple type
root
: ['products'] as
const
,
byId
: (
id
: string) => [...
PRODUCT_QUERY_KEYS
.
root
,
id
] as
const
,
}

Here's how to refactor the product detail example using defineQueryOptions() and key factories:

ts
import { 
getProductById
} from '@/api/products'
import {
defineQueryOptions
} from '@pinia/colada'
export const
PRODUCT_QUERY_KEYS
= {
root
: ['products'] as
const
,
byId
: (
id
: string) => [...
PRODUCT_QUERY_KEYS
.
root
,
id
] as
const
,
} export const
productDetailsQuery
=
defineQueryOptions
((
id
: string) => ({
key
:
PRODUCT_QUERY_KEYS
.
byId
(
id
),
query
: () =>
getProductById
(
id
),
}))
vue
<script setup lang="ts">
import { 
useQuery
} from '@pinia/colada'
import {
useRoute
} from 'vue-router'
import {
productDetailsQuery
} from '@/queries/products'
import
ProductItemDetail
from '@/components/ProductItemDetail.vue'
const
route
=
useRoute
()
// Pass the defined query options to useQuery const {
state
:
product
,
asyncStatus
} =
useQuery
(
productDetailsQuery
,
() =>
route
.
params
.
id
as string,
) </script> <template> <
main
>
<LoadingIndicator v-if="
asyncStatus
=== 'loading'" />
<
div
v-if="
product
.
error
">
<ErrorMessage
:
error
="
product
.
error
" />
</
div
>
<
div
v-else
class
="flex flex-wrap">
<ProductItemDetail
:
product
="
product
.
data
" />
</
div
>
</
main
>
</template>

Now you can reuse productDetailsQuery anywhere in your app, and if you need to change how product details are fetched, you only update it in one place. You can also access the cached data with full type safety:

ts
import { 
useQueryCache
} from '@pinia/colada'
import {
productDetailsQuery
} from '@/queries/products'
const
queryCache
=
useQueryCache
()
// Typed and type-safe access to cached data 💯 const
cachedProduct
=
queryCache
.
getQueryData
(
productDetailsQuery
('24').
key
)
/* * Setting data is also type-safe */
queryCache
.
setQueryData
(
productDetailsQuery
('24').
key
, {
id
: '24',
name
: 'New Product Name',
color
: 'red',
// oops... imagesrc: '...',
Object literal may only specify known properties, and 'imagesrc' does not exist in type 'NoInfer<ProductT> | ((oldData: ProductT | undefined) => ProductT)'.
imageAlt
: '...',
price
: 65,
availability
: 5,
rating
: 4.5,
description
: '...',
})

TIP

For simple, one-off queries that won't be reused, inline options with useQuery() are fine. But as soon as you find yourself using the same query in multiple places, or need to access its cached data from mutations or other queries, reach for defineQueryOptions() and key factories.

The Query Cache

In the mutation example we introduce the usage of the Query Cache. It allows us to access and modify the cache from anywhere as well as trigger cache invalidations. It's a powerful tool that enables us to write decoupled Mutations and Queries in a well organized way.

It's also what makes Pinia Colada extensible! It's a Pinia store under the hood, so it's easy to intercept actions with $onAction() and create plugins that enhance its capabilities.

Going further

Pinia Colada offers a wealth of features to enhance your application's performance and user experience, all while helping you write more maintainable code. Explore the following guides to learn more:

Released under the MIT License.