Most Vue teams we've worked with end up building custom server-state logic inside Pinia stores, duplicating loading and error states across dozens of queries. TanStack Query with Vue eliminates that boilerplate by centralizing data fetching, caching, and synchronization. We'll walk through real-world lessons from production systems handling high-frequency updates and stale data challenges.
Understanding useQuery caches and refetches
The core of TanStack Query is useQuery, which manages server state with built-in caching and automatic refetch logic. Unlike Pinia stores where you manually track loading and error states, useQuery handles all of that behind the scenes. Each query is keyed uniquely; if two components request the same data simultaneously, TanStack Query deduplicates the network request and serves both from cache.
Let's look at a practical example from an e-commerce platform fetching product details:
import { useQuery } from '@tanstack/vue-query'
import { fetchProduct } from '~/api/products'
export default defineComponent({
setup() {
const productId = ref(123)
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['product', productId.value],
queryFn: () => fetchProduct(productId.value),
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
})
return { data, isLoading, error, refetch }
}
})
staleTime controls how long data is considered fresh; queries within this window skip the network. gcTime determines how long cached data persists after all components using it unmount. In real systems, we set staleTime to match your API's SLA—product catalogs might use 5 minutes, user preferences might use 30 seconds. Setting it too high risks showing outdated inventory; too low hammers your backend.
Refetch strategies matter on high-traffic pages. A dashboard we optimized was refetching analytics data every component mount, creating unnecessary spike load. We added enabled: false initially, then triggered refetch explicitly when users clicked a refresh button or after mutations:
const { refetch, isRefetching } = useQuery({
queryKey: ['analytics', dateRange.value],
queryFn: () => fetchAnalytics(dateRange.value),
enabled: false, // Manual control
})
const handleRefresh = async () => {
await refetch()
}
useMutation for writes and state updates
While useQuery handles reads, useMutation manages writes—API calls that change data. Unlike queries, mutations don't cache; each invocation hits the API. This is where you update products, create orders, or change user settings.
A real-world scenario from a SaaS team: updating user preferences. Without TanStack Query, you'd track loading and error state manually in Pinia, duplicate error messages across templates, and forget to reset loading on edge cases. useMutation gives you all of that for free:
import { useMutation } from '@tanstack/vue-query'
import { updateUserPreferences } from '~/api/users'
export default defineComponent({
setup() {
const { mutate, isPending, error, isError } = useMutation({
mutationFn: (preferences) => updateUserPreferences(preferences),
onSuccess: (data) => {
// Handle success
console.log('Preferences updated:', data)
},
onError: (err) => {
// Handle error
console.error('Update failed:', err.message)
}
})
const handleSave = (newPrefs) => {
mutate(newPrefs)
}
return { handleSave, isPending, error, isError }
}
})
The callback lifecycle—onMutate, onError, onSuccess—lets you implement optimistic updates, rollbacks, and error recovery without custom logic. We've used onMutate to update local state immediately while the request is in-flight, improving perceived performance in ride-hailing apps where every 100ms matters:
const { mutate } = useMutation({
mutationFn: (newStatus) => updateRideStatus(newStatus),
onMutate: async (newStatus) => {
// Cancel ongoing queries
await queryClient.cancelQueries({ queryKey: ['ride'] })
// Save old data for rollback
const oldData = queryClient.getQueryData(['ride'])
// Update cache immediately
queryClient.setQueryData(['ride'], { status: newStatus })
// Return context for onError
return { oldData }
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.oldData) {
queryClient.setQueryData(['ride'], context.oldData)
}
}
})
Invalidating queries after changes
The biggest mistake we see is mutations completing successfully but the UI showing stale data because related queries weren't refetched. TanStack Query solves this with queryClient.invalidateQueries, which marks queries as stale and forces a refetch.
Imagine a travel booking system where users add trips. After creating a trip, the trip list query is stale, but the component doesn't know to refetch. Using mutation callbacks, we invalidate the affected queries:
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { createTrip } from '~/api/trips'
export default defineComponent({
setup() {
const queryClient = useQueryClient()
const { mutate } = useMutation({
mutationFn: (tripData) => createTrip(tripData),
onSuccess: () => {
// Refetch all trip-related queries
queryClient.invalidateQueries({ queryKey: ['trips'] })
// Or be more specific
queryClient.invalidateQueries({
queryKey: ['trips', 'list']
})
}
})
return { mutate }
}
})
Specificity matters. If you invalidate all queries broadly, you'll refetch unrelated data and create unnecessary network traffic. In a CRM we optimized, invalidating the entire ['contacts'] key after updating one contact's email was refetching paginated lists, filters, and counts. We tightened it to ['contacts', id] for that specific contact, plus the parent list if needed. The result: 70% fewer requests on bulk operations.
Parallel invalidation is common in complex flows. When users delete a project in a project management app, you might need to invalidate the project list, project details, and team dashboards:
const { mutate } = useMutation({
mutationFn: (projectId) => deleteProject(projectId),
onSuccess: (deletedProjectId) => {
Promise.all([
queryClient.invalidateQueries({ queryKey: ['projects'] }),
queryClient.invalidateQueries({ queryKey: ['teams'] }),
queryClient.invalidateQueries({ queryKey: ['dashboards'] })
])
}
})
Less custom store code
The real win of TanStack Query with Vue is eliminating boilerplate. Without it, a typical Pinia store for fetching user data looks like this:
// Before: Custom Pinia store
const useUserStore = defineStore('user', {
state: () => ({
user: null,
loading: false,
error: null
}),
actions: {
async fetchUser(id) {
this.loading = true
this.error = null
try {
this.user = await api.getUser(id)
} catch (err) {
this.error = err.message
} finally {
this.loading = false
}
}
}
})
Now every query needs this pattern. With TanStack Query, it's one line:
// After: TanStack Query
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', id],
queryFn: () => api.getUser(id)
})
Scaling across 50+ queries, this saves hundreds of lines of repetitive state management. We've migrated several Nuxt apps from custom Pinia fetch logic to TanStack Query and consistently reduced store code by 60–70%. The mental model is clearer too—Pinia handles app state (user preferences, UI toggles), TanStack Query handles server state (data from APIs). Separation of concerns.
Common mistakes
- Changing queryKey without resetting cache: If you update a query key (e.g., filter value), the old cached query persists. Components may show stale data until manual refresh. Always verify queryKey dependencies are reactive and tracked correctly.
- Over-aggressive staleTime: Setting
staleTimeto hours for frequently-changing data like stock prices or ride availability causes users to see outdated information. Match your data's mutation frequency. - Forgetting to invalidate after mutations: A mutation succeeds but related queries aren't marked stale. The UI shows the old state until the next manual refetch or page reload. Always pair mutations with
invalidateQueries. - Mixing server state in Pinia: Storing API data in Pinia alongside TanStack Query creates a dual source of truth. One updates, the other doesn't, and debugging becomes a nightmare. Keep server data in TanStack Query only.
- Not handling network errors in UI:
errorfrom useQuery is the exception object, not a user-friendly message. Always map to readable text or use error boundaries in Nuxt.
Key takeaways
- Use
useQueryfor reads with automatic deduplication, caching, and refetch strategies based onstaleTimeandgcTime. - Use
useMutationfor writes and leverage callbacks (onMutate,onSuccess,onError) for optimistic updates and error handling. - Always invalidate affected queries after mutations to keep the cache in sync with the server.
- Keep queryKey structures consistent and hierarchical—use arrays like
['resource', id, 'subresource']for granular invalidation. - Replace custom server-state logic in Pinia with TanStack Query; use Pinia only for true app state (UI toggles, user preferences).
- Tune
staleTimeto your data's lifecycle and monitor network requests to catch over-fetching.






Comments
Loading comments…