Data fetching with Pinia Colada
Load and cache server data with useQuery(), give each item its own key, change data with useMutation(), and keep lists fresh by invalidating queries.
What you'll learn
When you finish, you can do these.- 1Load and cache a product list with useQuery()
- 2Give each product its own key and set when data goes stale
- 3Add a product with useMutation() and refresh every list
- 4Show a loading message on the first load only
Picture the product list of a small online shop. It needs a loading message, an error message when the server fails, a cache (a stored copy, so the list is not fetched twice) and a fresh list after someone adds a product. Written by hand, that means a variable for each message, and code that fetches again, in every component that loads data.
Pinia Colada, a library for loading data, does that work. It is built on Pinia, Vue's official library for shared state, and keeps what it loads in one cache. Each piece, called a query, is filed under a key, a name such as ['products']. The Code tab holds the shop's files, to read rather than run, since their server is imagined: a product list, a page for one product and a form that adds one. By the end you can read every line of them. You need one idea from the lesson Persisting state and plugins: a plugin, an add-on for Pinia, goes in main.ts, the file that starts the app, right after Pinia itself.
Read with useQuery
Loading the product list takes two steps. First, install the @pinia/colada package and add its plugin, PiniaColada, in main.ts right after createPinia().
createApp(App)
.use(createPinia())
.use(PiniaColada, {
queryOptions: { staleTime: 30_000 }, // the default is 5 seconds
})
.mount('#app')
queryOptions sets defaults for every query in the app. Here staleTime, in milliseconds, keeps loaded data fresh for 30 seconds instead of the default 5; after that it is stale, due to be fetched again.
Second, call useQuery({ key, query }) in the component that shows the data. key is an array that names the data in the cache, and query is the function that loads it.
The Code tab keeps the shop's server requests in one file, api/products.ts: getProducts() loads the product list, getProduct(id) loads one product and createProduct(name) adds one. The list itself is a component, ProductList.vue, that loads its data with getProducts.
const { state, asyncStatus, refetch } = useQuery({
key: ['products'],
query: getProducts,
})
state holds status, data and error. Its status is 'pending' until the first answer arrives, then 'success' or 'error'. asyncStatus is 'loading' while a request runs and 'idle' otherwise. The two are separate on purpose: a list can have data and be refreshing it at the same time. The template of ProductList.vue uses both.
<template>
<p v-if="state.status === 'pending'">Loading…</p>
<p v-else-if="state.status === 'error'">Could not load: {{ state.error?.message }}</p>
<template v-else>
<small v-if="asyncStatus === 'loading'">Updating…</small>
<ul>
<li v-for="p in state.data" :key="p.id">{{ p.name }}</li>
</ul>
</template>
<button @click="refetch()">Reload</button>
</template>
The page shows Loading… until the first answer, then the product names, or Could not load: and the reason. During a later request the names stay, with a small Updating… above them.
The Reload button at the bottom calls refetch(), which always fetches. refresh() fetches only when the cached data is stale or failed. Two components that use the same key share one request and one cache entry.
- useQuery(key)
- cachefresh? use it
- query()fetch
- mutation
- invalidate key
- refetch
Keys and freshness
A key can follow a value that changes, and freshness decides when cached data is fetched again.
A page that shows one product, ProductPage.vue in the Code tab, gets the product's id from its parent, the component that shows it. Make the key a function, and Colada reads it again whenever id changes.
const { id } = defineProps<{ id: number }>()
const { state } = useQuery({
key: () => ['products', id], // a function, so the key follows the prop
query: () => getProduct(id),
})
defineProps declares the props, the values from the parent, and const { id } takes id out of them. Read inside the arrow function, id is always the current prop, so each product gets its own cache entry: ['products', 1], ['products', 2].
An id read from the page's address with Vue Router, a route parameter, works the same way: key: () => ['products', route.params.id].
While data is fresh, a component that appears on the page, or mounts, gets the cached data with no request. Once it is stale, Colada refreshes it in the background when a component using the key mounts, when the browser tab becomes visible again and when the network comes back: refetchOnMount, refetchOnWindowFocus and refetchOnReconnect are all on by default.
Write with useMutation
A mutation sends a change to the server, then tells the cached lists to fetch again.
Here the change comes from a form that adds a product, AddProduct.vue in the Code tab. Its mutation, made with useMutation(), sends the new name with createProduct(). queryCache, which useQueryCache() returns, is the cache itself.
const { mutate, asyncStatus } = useMutation({
mutation: (newName: string) => createProduct(newName),
onSettled: () => queryCache.invalidateQueries({ key: ['products'] }),
})
Submitting the form calls mutate(name) with the name typed in its text box, and asyncStatus turns the Add product button off while the request runs.
onSettled runs after the server answers, whether it succeeded or failed. There invalidateQueries() marks queries as out of date. The ones on screen refetch at once, so every list updates without knowing the form exists, and the others refetch the next time a component uses them.
Keys match by prefix: invalidateQueries({ key: ['products'] }) reaches every key that starts with 'products', such as ['products'] and ['products', 3]. Add exact: true to reach only ['products'].
An optimistic update, which shows the new product before the server confirms it, adds two options, onMutate and onError. The Data & APIs lesson Mutations, optimistic updates and pagination builds one with TanStack Query, a similar library with the same option names.
What goes wrong
The bug everyone meets first is a key written as a plain array.
useQuery({
key: ['products', id],
query: () => getProduct(id),
})
The array is built once, when the component is set up, with the first id. When the parent changes id to 2, the key still says 1, no request is sent, and the page keeps showing product 1 without any warning. Pass a function.
useQuery({
key: () => ['products', id],
query: () => getProduct(id),
})
Colada reads the function again when id changes, and fetches product 2 under its own key, ['products', 2].
The second mistake uses asyncStatus to decide whether to show the data.
<p v-if="asyncStatus === 'loading'">Loading…</p>
<ul v-else>
Every background refresh, such as the one after you return to the tab, swaps the whole list for Loading… and the page jumps. Check state.status for the first load instead.
<p v-if="state.status === 'pending'">Loading…</p>
<template v-else>
<small v-if="asyncStatus === 'loading'">Updating…</small>
<ul>
The list stays on screen during a refresh, with a small Updating… above it.
What to remember
useQuery({ key, query })caches data under a key. Components that use the same key share one request and one entry.- Make the key a function when it depends on a prop, a route parameter or a ref. A plain array is read once.
- Data is fresh for
staleTime, 5 seconds by default. After that, mounting, returning to the tab or reconnecting refreshes it in the background. - A mutation invalidates by key prefix in
onSettled, and the queries on screen refetch. state.statussays whether you have data;asyncStatussays whether a request is running. Show Loading… only while the status is'pending'.
In your project
Do these in a real project. Each one says how to confirm it worked.- Replace one hand-written fetch, with its own loading and error variables, by
useQuery(). Open Vue DevTools and the Pinia tab: the cache shows as the_pc_querystore, with an entry for your key. - Add a mutation that invalidates the list's key in
onSettled. Submit it with the browser's Network tab open: one request for the mutation, then one request that refetches the list. - Set
staleTime: 60_000on the list query, next tokeyandquery. Leave the page and come back within a minute: the Network tab shows no new request, and the list appears at once.