Fetching, Caching and Error Handling Done Right
Loading data sounds trivial, until the first API request hangs in production, the loading state is unclear, or stale cache data shows the user a false picture of reality. Vue 3 and Nuxt provide mature primitives for data fetching that together cover caching, error handling and SSR compatibility.
Table of Contents
- 1. Why data fetching in Vue is more than a fetch() call
- 2. The useData composable: fetching with loading and error state
- 3. useFetch and useAsyncData in Nuxt 3
- 4. Caching strategies: from a simple memo to SWR
- 5. Error handling: network errors, API errors and timeouts
- 6. Designing loading states and skeleton UX correctly
- 7. Pagination and infinite scrolling
- 8. TanStack Query (Vue Query) for complex use cases
- 9. Fetching approaches compared
- 10. Summary
- 11. FAQ
1. Why data fetching in Vue is more than a fetch() call
Anyone loading data in Vue for the first time writes a fetch() call in onMounted and stores the result in a ref. That works for simple prototypes, but it quickly becomes incomplete: error handling is missing, the loading state is not defined, navigating back to the route reloads the data unnecessarily, and with Nuxt SSR the data gets fetched twice on the client. Each of these gaps is a potential source of failure in production.
The fundamental problem is that loading data in Vue is an asynchronous process with multiple states: initial state (no request yet), loading (request in flight), success (data available), error (request failed), revalidating (data is being refreshed in the background). A complete fetching system models all of these states explicitly and makes them reactively available to the template. Vue 3's Composition API is the ideal tool for this, because composables cleanly encapsulate this state machine and make it reusable.
2. The useData composable: fetching with loading and error state
The basic pattern for loading data in Vue is a useData composable that translates the asynchronous state into reactive refs. It accepts a fetch function, calls it, and exposes data, isLoading, error and refresh. These four values are all a component needs for a complete UI: a skeleton while loading, an error message on failure, data on success, and a way to refresh manually.
An important detail: the fetch function should be passed as a parameter, not as a URL string. That makes the composable universal, it does not matter whether fetch(), axios, or a typed API client is used internally. Dependencies can also be passed in as reactive refs: when a filter parameter changes, that automatically triggers a new request. This pattern prevents the common bug where filter changes fail to update the data list because no watcher was set on the dependency.
// composables/useData.ts, universal data fetching composable
import { ref, watch, type Ref } from 'vue'
interface UseDataOptions<T> {
immediate?: boolean // fetch on composable creation (default: true)
initialData?: T // data to show before first fetch completes
}
interface UseDataReturn<T> {
data: Ref<T | null>
isLoading: Ref<boolean>
error: Ref<string | null>
refresh: () => Promise<void>
}
export function useData<T>(
fetchFn: () => Promise<T>,
options: UseDataOptions<T> = {}
): UseDataReturn<T> {
const { immediate = true, initialData = null } = options
const data = ref<T | null>(initialData as T | null)
const isLoading = ref(false)
const error = ref<string | null>(null)
async function refresh() {
isLoading.value = true
error.value = null
try {
data.value = await fetchFn()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
} finally {
isLoading.value = false
}
}
if (immediate) refresh()
return { data, isLoading, error, refresh }
}
// Usage with reactive filter dependency
// const filter = ref({ category: 'shoes' })
// const { data, isLoading, error } = useData(() => fetchProducts(filter.value))
// watch(filter, () => refresh(), { deep: true })
3. useFetch and useAsyncData in Nuxt 3
Nuxt 3 ships two specialized composables for loading data in Vue with SSR: useFetch and useAsyncData. useFetch is the simpler entry point: it takes a URL, runs the request both on the server and on the client, and prevents the client from issuing the same request again through payload hydration. useAsyncData is the more flexible variant: it takes any asynchronous function plus a unique key that Nuxt uses to identify the payload for hydration. Both composables return the same reactive API: data, pending, error and refresh.
A crucial difference from a plain Vue composable: in Nuxt, these composables are invoked during server rendering, before the HTML is delivered. That means the data is already included in the initial HTML response, no visible loading state for the user, no layout shift from content loading in afterward, fully indexable content for search engines. Loading data in Vue with Nuxt is therefore a fundamentally different experience from purely client-side fetching.
4. Caching strategies: from a simple memo to SWR
Caching for loading data in Vue has several layers. The simplest is an in-memory memo inside a composable: the data is stored in a map after the first load and returned immediately on subsequent calls with the same key. That makes sense for data that rarely changes, category lists, configuration values, reference data. The weakness: the data is discarded on page reload and can become stale without the user noticing.
Stale-while-revalidate (SWR) is the more modern caching pattern for loading data in Vue: on invocation, the cached (possibly stale) data is shown immediately, while a new request runs in the background. When the new response arrives, the data updates reactively. The user never sees an empty loading state for already-cached content, only a brief moment with old data, minimized by the background refresh. Nuxt implements this pattern with the getCachedData option of useFetch, VueQuery implements it as default caching behavior.
// composables/useCachedData.ts, SWR-style caching for Vue 3
import { ref, type Ref } from 'vue'
// Module-level cache, survives component re-mounts within the same session
const cache = new Map<string, { data: unknown; timestamp: number }>()
interface CachedDataOptions {
ttl?: number // time-to-live in milliseconds (default: 60 seconds)
staleWhileRevalidate?: boolean
}
export function useCachedData<T>(
key: string,
fetchFn: () => Promise<T>,
options: CachedDataOptions = {}
) {
const { ttl = 60_000, staleWhileRevalidate = true } = options
const data = ref<T | null>(null) as Ref<T | null>
const isLoading = ref(false)
const isRevalidating = ref(false)
const error = ref<string | null>(null)
async function load() {
const cached = cache.get(key)
const now = Date.now()
const isFresh = cached && (now - cached.timestamp) < ttl
if (isFresh) {
// Cache is fresh, return immediately, no request needed
data.value = cached.data as T
return
}
if (cached && staleWhileRevalidate) {
// Show stale data immediately, revalidate in background
data.value = cached.data as T
isRevalidating.value = true
} else {
isLoading.value = true
}
try {
const result = await fetchFn()
data.value = result
cache.set(key, { data: result, timestamp: Date.now() })
} catch (e) {
if (!data.value) {
error.value = e instanceof Error ? e.message : 'Load error'
}
} finally {
isLoading.value = false
isRevalidating.value = false
}
}
load()
return { data, isLoading, isRevalidating, error, refresh: load }
}
5. Error handling: network errors, API errors and timeouts
Errors while loading data in Vue come in different forms: a network error means the server was never reached at all. A 4xx HTTP error means the request itself was wrong, invalid parameters, missing authorization, resource not found. A 5xx error indicates a server-side problem. A timeout occurs when the server responds, but too slowly. Each of these cases requires a different reaction: network errors justify a retry attempt, 401 errors require a redirect to the login page, 404 errors should show an empty view, 5xx errors a technical error message with the option to reload.
Vue 3 does not ship a built-in error boundary component like React, but the pattern can be recreated with onErrorCaptured. An ErrorBoundary component catches errors from all child components and shows a fallback UI instead of ending the entire application with a blank screen. Combined with v-if/v-else blocks in the template that distinguish between loading state, error state and success, this produces complete error handling for every scenario of loading data in Vue.
6. Designing loading states and skeleton UX correctly
The loading state is the frequently neglected part of loading data in Vue. A spinner in the middle of the screen is the minimum, but not the optimum. Skeleton screens that hint at the approximate structure of the loaded content significantly reduce perceived load time. The user immediately sees where which content will appear, and the page feels active rather than empty and waiting. In Vue, skeleton screens are implemented as separate components that share the same basic structure as the actual content component, but with animated placeholder shapes instead of real data.
Another important aspect of the loading state when loading data in Vue is the distinction between the initial load and revalidation. During the initial load there is no content yet, here a skeleton screen makes sense. During background revalidation (SWR), content is already visible, a subtle indicator such as a small spinner in the top right or a thin progress bar is better than fully replacing it with a skeleton screen. The rule: never replace existing content with a skeleton, that degrades the UX because the user loses the information already shown.
7. Pagination and infinite scrolling
Pagination is a common use case when loading data in Vue that affects the caching strategy. Page-based pagination with explicit next/previous buttons loads a specific page of results and fully replaces the current data list. Infinite scroll, by contrast, appends new results to the existing list. Both patterns have different implications: page-based pagination is easier to cache and backward compatible with the browser's back navigation. Infinite scroll produces a better mobile UX, but is harder to debug when the user has scrolled deep into the list and the URL contains no position.
In Vue, infinite scroll is implemented with the IntersectionObserver API: an invisible sentinel element at the end of the list is observed. When it enters the viewport, the next request is triggered. In the composable, the full list is managed as a ref array to which new pages are appended, along with a hasMore flag indicating whether further pages exist. The data-loading composable in Vue exposes loadMore as a method that manages the page number internally and can only be invoked when no request is already in flight and more data is available.
8. TanStack Query (Vue Query) for complex use cases
For applications with complex fetching requirements, TanStack Query (formerly VueQuery) is a library that already implements all the patterns discussed. Its core is useQuery: it accepts a query key (for caching and invalidation), a fetch function and options. It returns data, isLoading, isFetching, error and refetch. Stale-while-revalidate is the default, caching and deduplication of identical requests happen automatically. That means: if ten components use the same query key, only one HTTP request is still sent.
The strength of TanStack Query for loading data in Vue lies in query invalidation: with queryClient.invalidateQueries(['products']), all queries with the key products are marked stale after a mutation and reloaded in the background. This solves the classic problem where the displayed list fails to update after a POST or DELETE because the refetch mechanism is missing. For simpler applications, a handful of API endpoints without complex interdependencies, an own composable is sufficient. Past a certain level of complexity, the time invested in learning TanStack Query pays off quickly.
9. Fetching approaches compared
The choice of fetching approach for loading data in Vue depends on application complexity, SSR requirements and the desired depth of caching.
| Approach | Caching | SSR-compatible | Recommended for |
|---|---|---|---|
| onMounted + fetch | None | No | Prototypes, simple SPAs |
| useData composable | Optional, manual | Manually possible | SPAs up to medium complexity |
| Nuxt useFetch | Payload hydration | Yes, automatically | Nuxt projects, SSR |
| TanStack Query | SWR, invalidation | With Nuxt plugin | Complex data dependencies |
| Pinia + actions | In store state | With $patch hydration | Cross-feature data |
For new Nuxt projects, useFetch/useAsyncData is the clear standard. For plain Vue 3 SPAs with no SSR requirement, an own useData composable for simple cases and TanStack Query for complex data dependencies is the best combination. Using Pinia actions as the primary fetching mechanism only makes sense when the loaded data genuinely needs to be shared across features.
Mironsoft
Vue 3 and Nuxt development, performance and data-layer engineering
Loading data in Vue without robust error handling and caching?
We build complete data-layer solutions in Vue 3 and Nuxt, from simple composables to TanStack Query with SWR, full error handling and SSR compatibility.
Data-layer design
Fetching strategy, caching and error handling for your Vue project
Nuxt migration
Migration from client-side fetching to Nuxt useFetch with SSR
Performance audit
Analysis of waterfall requests, duplicate fetching and missing caching
10. Summary
Professional data loading in Vue is more than a fetch() call in onMounted. It requires explicit modeling of all states: loading, success, error and revalidating. The useData composable encapsulates this state machine and makes it reusable. In Nuxt projects, useFetch and useAsyncData additionally take care of SSR hydration and prevent duplicate requests. Caching with stale-while-revalidate improves perceived performance by showing stale data immediately and updating it in the background.
Error handling when loading data in Vue distinguishes between network errors (retry), 4xx errors (application response) and 5xx errors (technical message). Loading states with skeleton screens instead of spinners measurably improve UX. For complex applications with many data dependencies and mutations, TanStack Query is worthwhile, it already implements all the patterns discussed and ensures data consistency after mutations through query invalidation.
Loading Data in Vue & Nuxt, the essentials at a glance
Model the states
Always model all four states explicitly: loading, success, error, revalidating. Never store just the data without an error and loading state.
Nuxt fetching
useFetch for SSR with automatic hydration. useAsyncData for custom fetch functions. Both prevent duplicate requests.
Caching
Stale-while-revalidate shows cached data immediately and refreshes it in the background. Never show a skeleton instead of existing content.
TanStack Query
For complex data dependencies: automatic SWR, query deduplication and invalidation after mutations. Pays off quickly.