There are two kinds of state in a Vue app, and they have almost nothing in common. Client state is what the user is doing right now — a draft message, an open modal, a selected tab. You own it; it exists nowhere else. Server state is a copy of data that lives on a server somewhere. You don't own it; you're displaying a snapshot, and the snapshot can go stale at any moment.
The mistake that keeps showing up is treating these as the same thing — usually by stuffing both into Pinia. Once you separate them, a whole class of bugs disappears.
What Makes Server State Hard
When you put a ref next to a watch and call fetch from a Pinia action, here's what you're not handling:
- Staleness. The user is on a screen for ten minutes. The server changes. Your screen is now lying.
- Cancellation. A search box fires three requests; they return out of order; the wrong one wins.
- Deduplication. Two components ask for
/meat the same time. Two requests, same answer, twice. - Caching across screens. Navigate away and back. Refetch. The user stares at a spinner for data they had two seconds ago.
- Refetching on focus. The user comes back from another tab. The data is hours old. No one tells you.
- Mutation invalidation. You PATCH a user. The list of users still shows the old name until refresh.
You can solve every one of these with Pinia. People do. It takes hundreds of lines of fragile code, and it's almost always wrong somewhere subtle. The right move is to admit this is a solved problem and pick a library that already solved it.
The Hand-Rolled Pinia Version, So You See What You're Replacing
export const useUsersStore = defineStore('users', () => {
const cache = ref<Record<number, User>>({})
const loading = ref<Record<number, boolean>>({})
const errors = ref<Record<number, string>>({})
async function fetchUser(id: number) {
if (cache.value[id] || loading.value[id]) return
loading.value[id] = true
try {
cache.value[id] = await api.users.get(id)
} catch (err) {
errors.value[id] = err.message
} finally {
loading.value[id] = false
}
}
return { cache, loading, errors, fetchUser }
})
Forty lines. Doesn't handle: cancellation, refetch on focus, retries, stale-while-revalidate, deduplication across simultaneous calls, mutation invalidation, refetch on reconnect, garbage collection of unused entries. Every one of those is another patch on top.
What A Server-State Library Gives You
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
const props = defineProps<{ id: number }>()
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', props.id],
queryFn: ({ signal }) => api.users.get(props.id, { signal }),
})
</script>
<template>
<Spinner v-if="isLoading" />
<Error v-else-if="error" :error="error" />
<Profile v-else :user="user" />
</template>
Same component, ten lines instead of forty, and now you get:
- Caching by
queryKey. Mount the component twice with the same id, one fetch. Navigate away and back, served from cache, refetched in the background. - Cancellation — pass
signalthrough to your HTTP client and the in-flight request aborts when the key changes. - Refetch on focus / reconnect — the user comes back, fresh data fetches in the background.
- Stale-while-revalidate — they see cached data instantly while the new data loads.
- Retries with exponential backoff on network errors.
- A single source of truth — everywhere in the app that asks for
['user', 1]reads from the same cache.
You did not write any of this. You don't have to test it.
The Three Real Choices In Vue
TanStack Query (@tanstack/vue-query). The mature, battle-tested option. Same architecture as the React version, identical mental model. Best documentation. The default I reach for.
Pinia Colada (@pinia/colada). Newer, designed by the Pinia team specifically for Vue. Smaller surface, integrates naturally with Pinia's ergonomics. If you want a query library that "feels like Pinia", this is it.
VueUse useFetch. Lighter-weight; not a full cache layer but covers the common case of "I just need the data here". Worth knowing about for small apps where the bigger libs are overkill.
// Pinia Colada example
import { useQuery } from '@pinia/colada'
const { data: user, isPending } = useQuery({
key: () => ['user', props.id], // getter returning the key array
query: () => api.users.get(props.id),
})
The shape is intentionally similar across all three — once you know the pattern, switching libs is a small refactor.
Mutations And Invalidation
The other half of server state is changing it. Every library has a matching primitive:
import { useMutation, useQueryClient } from '@tanstack/vue-query'
const qc = useQueryClient()
const update = useMutation({
mutationFn: (next: Partial<User>) => api.users.update(props.id, next),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['user', props.id] })
qc.invalidateQueries({ queryKey: ['users'] })
},
})
After a successful PATCH, invalidateQueries marks matching cache entries as stale. Any component currently rendering them refetches in the background. The header avatar, the user list, the profile page — they all update without you wiring anything up. This is the part that's almost impossible to do correctly by hand.
"But It's Just One Endpoint…"
The hand-rolled shape is genuinely fine for exactly one fetch on mount, never updated, never shared, never refetched, never invalidated. There aren't many such fetches in real apps. The moment a second component needs the same data, or the user can edit it, or the screen lives long enough to go stale — you've crossed into server-state territory.
The "I don't need a library" reasoning is how a Vue codebase ends up with twelve different ad-hoc caching schemes inside Pinia stores, each with its own bugs. Pick one server-state library, use it everywhere, and stop reinventing the cache.
What Stays In Pinia
Plenty:
- Auth context — current user, permissions, tokens. Read once after login, rarely changes during a session, used everywhere.
- App-wide UI state — sidebar collapsed, theme, command-palette open. Cross-cutting, mostly user-driven.
- Feature flags — derived from a remote config (which itself goes through a query lib for refresh), but exposed to consumers via a Pinia store for stable identity.
- Real-time state from a WebSocket that needs to be globally reactive. The Pinia store wraps the connection; consumers subscribe through it.
Anything else, ask: did this come from the server, and could the server change it without me asking? If yes, it's server state.
A One-Sentence Mental Model
Pinia is for state you own. Server data is state you're borrowing. Borrow it through a library that knows how to keep the copy fresh, keep your Pinia stores focused on client state, and most of the "why is this stale?" bugs disappear before they're filed.



