Query libraries, TanStack Query and Pinia Colada
Share one cache of server data across components with TanStack Query or Pinia Colada: name each entry with a key, throw on a failed response, and decide how long cached data stays fresh.
What you'll learn
When you finish, you can do these.- 1Share one cache of server data with TanStack Query
- 2Put every value a request uses into its query key
- 3Decide how long cached data stays fresh with staleTime
- 4Choose between TanStack Query and Pinia Colada
Picture a team page that shows its list of people twice, in a sidebar and in the main panel. When each list loads its own data, the server gets two requests, the page holds two copies that can disagree, and Loading… shows again each time a list is hidden and shown. A query library keeps one cache, a shared copy of the server's data, for the whole app. Components ask it for data by name, and it fetches only when it has to.
The Code tab holds a small team page built with TanStack Query, one such library: UserList.vue lists the team, UserCard.vue shows the person picked in a menu, and a Requests line counts what reaches the server. A checkbox adds the same list from Pinia Colada, a second query library. You need one idea from the lesson A reusable fetch composable: a getter, a function such as () => id that returns the current value each time it runs.
One cache, shared by key
Sharing starts with one cache, installed once as a plugin: an add-on that .use() attaches in main.ts, the file that starts the app. A QueryClient holds TanStack Query's cache and its defaults for every query, and VueQueryPlugin gives every component access to it.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 0, // the default. Try it 2: 30_000
retry: 1, // the default is 3 retries, 1 s, 2 s and 4 s apart
},
},
})
createApp(App)
.use(VueQueryPlugin, { queryClient })
The two settings, staleTime and retry, come up further down.
A component asks for data with useQuery(), as the team list in UserList.vue does. queryKey is the name of the data in the cache, an array, and queryFn is the async function that loads it, here getUsers from api.ts.
const { data: users, isPending, isFetching, isError, error } = useQuery({
queryKey: ['users'],
queryFn: getUsers,
})
useQuery() returns refs: isPending is true while there is no data yet, isFetching while any request runs, even a refresh, and isError once the query has failed, with the reason in error. The preview shows Loading…, then the heading Team over Asha, Ben and Chen.
The key does the sharing: every component that uses ['users'] reads the same cache entry, and a second component waits for a request already running instead of sending its own.
- Component A and B
- Cache by key
- One request
- Server
The preview has no network: api.ts imports a stand-in fetch() from fakeServer.ts, which answers after 0.6 seconds and counts each path's requests on the Requests line: /api/users ×1 for the list, /api/users/1 ×1 for Asha on the card.
A query fails only when its function throws, and fetch() does not throw for an error status such as 500: it resolves, with res.ok set to false. So getJson, the helper in api.ts that every request goes through, checks res.ok and throws:
async function getJson<T>(path: string): Promise<T> {
const res = await fetch(path)
if (!res.ok) throw new Error(`Request failed with ${res.status}`)
return res.json()
}
A failed query is retried before the error shows, three times by default. With the starter's retry: 1, tick the Server answers 500 box and pick someone new: after about two seconds the card says Request failed with 500.
Keys with parameters
A list needs one key; a card for one person needs a key per person. The card, UserCard.vue, shows whoever is picked in the Person menu and gets that person's id as a prop. Put every value the query function uses into the key, so each person gets an entry of their own:
const { id } = defineProps<{ id: number }>()
const { data: user, isPending, isError, error } = useQuery({
queryKey: ['users', () => id],
queryFn: () => getUser(id),
})
() => id is a getter, a function that returns the current id, so TanStack reads the key again when the prop changes and loads the new entry. A plain id would be read once, when the card is created.
Pick Ben in the Person menu: Loading… shows, because his entry is new, then Ben lives in Nairobi. Pick Asha again: she appears at once from the cache, and the Requests line counts one more /api/users/1, a background refresh.
Fresh, stale and Pinia Colada
A cache needs a rule for when to ask the server again: staleTime, how long cached data counts as fresh. TanStack's default is 0, so data is stale as soon as it arrives.
Stale data is still shown, and refreshed in the background when a component using the key mounts (appears on the page), when the browser tab becomes visible again and when the network returns. With staleTime: 30_000, 30 seconds written in milliseconds, a component that mounts within that time gets the cached list and sends nothing.
During a background refresh isPending stays false and isFetching is true. UserList.vue shows Loading… only while isPending is true, so the list stays on screen with a small Updating… beside its heading.
Pinia Colada builds the same idea on Pinia, the library that keeps shared state in stores. Tick the Show the Pinia Colada list box to add ColadaList.vue, the team list loaded with Colada. It sends a request of its own: the two libraries keep separate caches. Three things differ:
- Names:
keyandqueryinstead ofqueryKeyandqueryFn,state.statusinstead ofisPendingandisError, andasyncStatusinstead ofisFetching. - Defaults: Colada data stays fresh for 5 seconds, and a failed query is not retried without its retry plugin.
- Home: Colada keeps its cache in a Pinia store, shown in the Pinia tab of Vue DevTools. TanStack Query brings its own devtools, and the same API exists for React, Solid and Svelte.
Both also handle paged lists and changes to data. In an app built on Pinia, Colada fits in; on a team that also writes React, TanStack Query means one API to learn, not two.
What goes wrong
Both mistakes show no error. The first drops the res.ok check from getJson, often by writing the one-liner fetch(path).then(res => res.json()).
async function getJson<T>(path: string): Promise<T> {
const res = await fetch(path)
return res.json()
}
Tick the Server answers 500 box and pick someone new. The card reads "lives in ." with gaps where the name and city belong, and no error appears. The server's error body, { "error": "Database unavailable" }, was parsed and stored as the person, so isError stays false and nothing retries.
async function getJson<T>(path: string): Promise<T> {
const res = await fetch(path)
if (!res.ok) throw new Error(`Request failed with ${res.status}`)
return res.json()
}
With the check, the card says Request failed with 500.
The second mistake leaves the id out of the key.
useQuery({
queryKey: ['user'],
queryFn: () => getUser(id),
})
Pick Ben and the card keeps saying Asha lives in Lisbon, with no warning: the key never changes, so the cache keeps answering with Asha's entry and sends no request.
useQuery({
queryKey: ['users', () => id],
queryFn: () => getUser(id),
})
With the id in the key, picking Ben shows Loading…, then Ben lives in Nairobi.
What to remember
- A query library keeps one cache. Components that call
useQuery()with the same key share one entry and one request. - Put every value the query function uses into the key, as a getter or a ref when it can change.
staleTimedecides when cached data is refreshed: 0 in TanStack Query, 5 seconds in Pinia Colada. Stale data stays on screen, so show Loading… only whileisPendingis true.- Pinia Colada does the same job and fits an app built on Pinia; TanStack Query fits a team that also writes React.
- The query function must throw for a failed response: check
res.ok, orisErrornever turns true.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In App.vue, replace the Try it 1 comment with a second
<UserList />. Two lists appear, and the Requests line still says/api/users ×1: the two shared one request. - Click Hide the list, then Show the list. The names come back at once with Updating… beside the heading, and
/api/usersgoes up by one: withstaleTime: 0the cached list was already stale. In main.ts setstaleTime: 30_000and repeat: the count stays. - In UserCard.vue, change the key to
['user']and pick Ben. The card keeps saying Asha lives in Lisbon and no/api/users/2appears. Put['users', () => id]back and pick Ben again: Loading…, then Ben lives in Nairobi.