The data-fetching problem we all face
You've shipped a Vue component to production. Users load it, and sometimes they see stale data, sometimes a loading spinner that never resolves, and sometimes the entire page crashes because a network error wasn't handled. We've all been there. Fetching data in Vue: Vue & Nuxt lessons from production means learning how to load server data reliably—handling the network contract properly, managing state transitions, and cleaning up after yourself when components unmount.
In this article, we'll walk through the patterns we've learned from production systems at scale, whether you're using vanilla Vue or Nuxt. The difference between a brittle data layer and a robust one often comes down to four things: where you fetch, what state you track, how you cache, and how you clean up.
Fetch in setup or onMounted
The first decision is where to trigger your data fetches. In Vue 3 with the Composition API, you have two main options: setup() or onMounted(). This choice matters more than it might seem.
Why setup vs onMounted? Setup runs during the component setup phase, before the component is mounted to the DOM. OnMounted runs after the DOM is ready. For most data fetches, onMounted is the safer choice because it runs in the browser where you have access to reactive state and lifecycle hooks. Setup can run during server-side rendering in Nuxt, which complicates things.
Here's a typical production pattern:
import { ref, onMounted } from 'vue'
export default {
setup() {
const user = ref(null)
const error = ref(null)
const loading = ref(false)
onMounted(async () => {
loading.value = true
error.value = null
try {
const response = await fetch('/api/user/profile')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
user.value = await response.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
})
return { user, error, loading }
}
}
If you're using Nuxt and need data available before the page renders (for SEO, for example), use useFetch or useAsyncData at the top level of your component. These composables understand Nuxt's rendering lifecycle:
// Nuxt 3 page component
export default definePageComponent({
async setup() {
const { data: user, pending, error } = await useFetch('/api/user/profile')
return { user, pending, error }
}
})
The key difference: in Nuxt, useFetch handles both server-side and client-side rendering automatically. In vanilla Vue, stick with onMounted for safety.
Track loading and error states
A fetch without state tracking is a fetch that will fail silently in production. Users won't know if data is loading, if an error occurred, or if they're just looking at stale content. We've learned to always track three states: loading, error, and data.
Here's why this matters in a real scenario: a user on a slow 3G connection loads your product detail page. If you don't track loading state, the UI shows nothing for 5 seconds, and the user refreshes. If you don't track error state and the API returns a 500, the page looks frozen. If you don't track data separately from loading, you might show an old product while the new one loads.
Here's a composable we use across multiple projects:
// composables/useFetchData.ts
import { ref, Ref } from 'vue'
export function useFetchData<T>(
url: string,
options: { timeout?: number } = {}
): {
data: Ref<T | null>
loading: Ref<boolean>
error: Ref<string | null>
refetch: () => Promise<void>
} {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const fetch = async () => {
loading.value = true
error.value = null
try {
const controller = new AbortController()
const timeoutId = setTimeout(
() => controller.abort(),
options.timeout || 10000
)
const response = await fetch(url, { signal: controller.signal })
clearTimeout(timeoutId)
if (!response.ok) {
throw new Error(`API error: ${response.status}`)
}
data.value = await response.json()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Unknown error occurred'
} finally {
loading.value = false
}
}
return { data, loading, error, refetch: fetch }
}
Use it like this in your component:
export default {
setup() {
const { data: products, loading, error, refetch } = useFetchData(
'/api/products',
{ timeout: 8000 }
)
onMounted(() => refetch())
return { products, loading, error, refetch }
}
}
Notice the timeout: we set a hard limit on how long we'll wait. This prevents requests from hanging indefinitely. Also notice we separate error handling from the data state—you might want to show the last-known product while an error banner appears above it.
TanStack Query for caching and synchronization
Once you're shipping to production with thousands of users, you'll notice the same data being fetched hundreds of times in parallel. Two users open the product page simultaneously, and we fire two identical API calls. That's wasteful. TanStack Query (formerly React Query, now framework-agnostic) solves this with intelligent caching and request deduplication.
TanStack Query maintains a client-side cache keyed by query identifiers. When multiple components request the same data within a short window, only one network request fires. Behind the scenes, it also handles cache invalidation, background refetching, and stale-while-revalidate patterns.
Here's how we'd use it in a Vue component:
// First, install: npm install @tanstack/vue-query
import { useQuery } from '@tanstack/vue-query'
export default {
setup() {
const { data: user, isLoading, error, refetch } = useQuery({
queryKey: ['user', 'profile'],
queryFn: async () => {
const res = await fetch('/api/user/profile')
if (!res.ok) throw new Error('Failed to fetch user')
return res.json()
},
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000)
})
return { user, isLoading, error, refetch }
}
}
The benefits become clear at scale:
- Deduplication: Two components requesting user/profile simultaneously share one request.
- Stale-while-revalidate: Show cached data immediately, refetch in the background.
- Automatic retries: Failed requests retry with exponential backoff.
- Cache invalidation: After a mutation (like creating a post), invalidate related queries to force a refetch.
For mutations (POST, PUT, DELETE), use useMutation:
import { useMutation, useQueryClient } from '@tanstack/vue-query'
export default {
setup() {
const queryClient = useQueryClient()
const { mutate: createPost, isPending } = useMutation({
mutationFn: async (title: string) => {
const res = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify({ title })
})
if (!res.ok) throw new Error('Failed to create post')
return res.json()
},
onSuccess: () => {
// Invalidate the posts list so it refetches
queryClient.invalidateQueries({ queryKey: ['posts'] })
}
})
return { createPost, isPending }
}
}
This pattern ensures your UI stays in sync with the server without complex manual orchestration.
Cancel requests on unmount
A subtle but critical issue: if a user navigates away from a component while a fetch is in flight, the fetch completes anyway and tries to update state on an unmounted component. Vue warns you about this (memory leak), and in production it causes silent failures and resource waste.
The solution is to cancel in-flight requests when the component unmounts. Here's how:
import { ref, onMounted, onBeforeUnmount } from 'vue'
export default {
setup() {
const data = ref(null)
const abortController = new AbortController()
onMounted(async () => {
try {
const response = await fetch('/api/data', {
signal: abortController.signal
})
if (response.ok) {
data.value = await response.json()
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
// Request was cancelled, don't log as error
return
}
console.error('Fetch failed:', err)
}
})
onBeforeUnmount(() => {
abortController.abort()
})
return { data }
}
}
The AbortController API is native to modern browsers. When you call abort(), any in-flight fetch using that controller's signal throws an AbortError. Catch it and bail out gracefully.
If you're using TanStack Query, cancellation is handled automatically. Queries are cancelled when components unmount, so you don't need manual cleanup.
Common mistakes
Fetching in setup(): Setup runs during SSR in Nuxt, so fetching there means your data loads on the server, then refetches on the client. Use useFetch in Nuxt or onMounted in vanilla Vue.
Not tracking all three states: We often see code that tracks loading but not error, or tracks data but not loading. All three are essential. A user needs to know: is data coming? Did it fail? Is this stale?
Forgetting to cancel: Left a fetch running on unmount? Memory leak. Memory leaks compound—across thousands of users, they add up. Always cancel in-flight requests.
Over-fetching without caching: Before adding TanStack Query or similar, measure your API hit rate. If the same endpoint is called dozens of times per second, caching is non-negotiable.
Ignoring network conditions: Testing on localhost with fast internet masks real-world problems. Always test on throttled 3G. Set reasonable timeouts (8-10 seconds for most APIs).
Key takeaways
- Use
onMountedfor fetching in vanilla Vue; useuseFetchin Nuxt for SSR safety. - Always track three states: loading, error, and data. They're not optional in production.
- Implement request timeouts (8-10 seconds) and handle timeout errors explicitly.
- For shared data across components, use TanStack Query for deduplication, caching, and cache invalidation.
- Cancel in-flight requests on component unmount using
AbortControlleror rely on TanStack Query's automatic cleanup. - Test with throttled network conditions to catch real-world problems before they reach production.






Comments
Loading comments…