Data fetching in Vue
The single most useful framing: server state is not client state. It is cached, it goes stale, it can fail, and several components want the same copy. Putting it in a Pinia store by hand means implementing caching, deduplication, revalidation and race handling yourself. See ../07_state_managers/server_vs_client_state.md.
The naive version, and what breaks
const data = ref(null), error = ref(null), loading = ref(false);
watchEffect(async () => {
loading.value = true;
try { data.value = await (await fetch(`/api/users/${toValue(id)}`)).json(); }
catch (e) { error.value = e; }
finally { loading.value = false; }
});
Everything here works until it does not:
- Two components mounting at once fire two identical requests.
- Changing
idtwice quickly lets the slower first response overwrite the second — the out-of-order bug. Abort the previous request in the cleanup. - Nothing revalidates on window focus or reconnect, so the screen shows stale data indefinitely.
- Every consumer keeps its own copy, so two components can disagree.
You can fix each one. Doing so is how you end up writing a query library badly.
TanStack Query (Vue Query)
The framework-agnostic answer, and the one most transferable if you also know React.
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query';
const { data, isPending, error, refetch } = useQuery({
queryKey: ['user', id], // reactive: refetches when id changes
queryFn: () => api.getUser(toValue(id)),
staleTime: 60_000,
});
const qc = useQueryClient();
const { mutate } = useMutation({
mutationFn: api.updateUser,
onSuccess: () => qc.invalidateQueries({ queryKey: ['user'] }),
});
What you get for free: request deduplication by key, caching, background revalidation, retry with backoff, pagination and infinite scroll helpers, optimistic updates with rollback, and devtools. The reactive query key is the Vue-specific nicety — passing a ref refetches automatically, no watch needed.
staleTime versus gcTime is the distinction to have straight: staleTime is how long data is considered fresh (no refetch), gcTime is how long an unused cache entry survives before collection. Leaving staleTime at 0 and then complaining about refetch chatter is the common misconfiguration.
Pinia Colada
The Vue-native option, from the Pinia and Vue Router maintainers. Same problem space — caching, deduplication, invalidation, SSR — with a smaller API, Pinia as its only dependency, and first-class integration with Vue Router data loaders via defineColadaLoader.
Choose it when you are already all-in on the Vue ecosystem and want the router integration. Choose TanStack Query when the team knows it from React, or you want the larger feature surface and ecosystem.
Nuxt
Nuxt has its own layer, and using a query library on top of it is usually redundant for page-level data.
| Use for | |
|---|---|
useAsyncData |
any async work that must run on the server and transfer to the client |
useFetch |
the common case: a URL, with useAsyncData semantics wrapped around $fetch |
$fetch |
imperative calls — event handlers, mutations — not initial page data |
useLazyAsyncData |
same, but does not block navigation |
The rule that catches people: calling $fetch directly in setup double-fetches — once on the server, once again on the client during hydration — because there is no payload transfer. useAsyncData serialises the result into the payload so the client reuses it. Getting this wrong doubles your API load and is invisible in development.
server/api/ routes let you keep secrets and heavy aggregation on the server, which is often the reason to use Nuxt at all. See 11_nuxt.md.
Where Pinia still fits
A store is right for genuine client state — auth session, UI preferences, a multi-step wizard, a cart before checkout. It is also a reasonable place to expose server data: put the query inside a store getter so components consume one interface. What it should not be is a hand-written cache of fetched data with your own invalidation rules.
Interview angle
- “How do you fetch data in Vue?” - a query library (TanStack Query or Pinia Colada), or Nuxt’s
useAsyncDatain a Nuxt app. Hand-rolledwatchEffectfetching is fine for one screen and does not survive contact with caching, deduplication and race conditions. - “Why not put API data in Pinia?” - because you then own caching, staleness, deduplication and invalidation. Pinia is for client state; server state has different requirements and a library already solves them.
- “What is the out-of-order response bug and how do you fix it?” - a slow earlier request resolving after a fast later one and overwriting fresher data. Abort the previous request in the effect cleanup, or let a query library key by request and discard stale results.
- “
staleTimeversusgcTime?” -staleTimecontrols when a refetch is allowed;gcTimecontrols when an unused cache entry is discarded. They are independent, and conflating them produces either constant refetching or unexpectedly empty caches. - “Why does
$fetchin Nuxtsetupfetch twice?” - no payload transfer, so it runs on the server and again during hydration.useFetch/useAsyncDataserialise the result into the payload.