Data Fetching
Learn different strategies for fetching data when navigating between routes, from simple component-based loading to advanced preloading techniques.
Overview
When building single-page applications, you often need to fetch data from APIs when users navigate to different routes. Vue Router provides several strategies for data fetching, each with different trade-offs for user experience and performance.
Fetching Strategies
1. Fetch After Navigation (Component-Based)
- Navigate immediately and show loading state
- Fetch data in component lifecycle hooks
- Better perceived performance for fast navigation
2. Fetch Before Navigation (Route-Based)
- Fetch data before navigation completes
- Show data immediately when component renders
- Better for critical data that must be available
3. Parallel Fetching
- Fetch multiple data sources simultaneously
- Optimize loading performance
- Handle partial failures gracefully
Fetch After Navigation
Basic Implementation
Navigate first, then fetch data in the component:
vue
<template>
<div class="user-profile">
<!-- Loading State -->
<div v-if="loading" class="loading-container">
<div class="spinner"></div>
<p>Loading user profile...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="error-container">
<h3>Failed to load profile</h3>
<p>{{ error.message }}</p>
<button @click="fetchUserData">Retry</button>
</div>
<!-- Success State -->
<div v-else-if="user" class="profile-content">
<img :src="user.avatar" :alt="user.name" />
<h1>{{ user.name }}</h1>
<p>{{ user.bio }}</p>
<div class="stats">
<span>{{ user.posts }} posts</span>
<span>{{ user.followers }} followers</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { fetchUser } from '@/api/users'
const route = useRoute()
const loading = ref(false)
const user = ref(null)
const error = ref(null)
// Watch route params to refetch data
watch(
() => route.params.id,
(newId) => {
if (newId) {
fetchUserData(newId)
}
},
{ immediate: true }
)
async function fetchUserData(userId = route.params.id) {
loading.value = true
error.value = null
user.value = null
try {
user.value = await fetchUser(userId)
} catch (err) {
error.value = err
console.error('Failed to fetch user:', err)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-container {
text-align: center;
padding: 2rem;
color: #e74c3c;
}
.profile-content {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
</style>Advanced Loading States
vue
<script setup>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const loading = ref(false)
const user = ref(null)
const posts = ref([])
const error = ref(null)
// Computed loading states
const isInitialLoad = computed(() => loading.value && !user.value)
const isRefreshing = computed(() => loading.value && user.value)
const hasData = computed(() => user.value && posts.value.length > 0)
// Fetch user and posts in parallel
async function fetchData(userId) {
loading.value = true
error.value = null
try {
const [userData, userPosts] = await Promise.all([
fetchUser(userId),
fetchUserPosts(userId)
])
user.value = userData
posts.value = userPosts
} catch (err) {
error.value = err
} finally {
loading.value = false
}
}
// Optimistic updates
async function updateUser(updates) {
const originalUser = { ...user.value }
// Optimistically update UI
user.value = { ...user.value, ...updates }
try {
const updatedUser = await updateUserAPI(user.value.id, updates)
user.value = updatedUser
} catch (err) {
// Revert on error
user.value = originalUser
error.value = err
}
}
</script>Composable for Data Fetching
javascript
// composables/useAsyncData.js
import { ref, computed } from 'vue'
export function useAsyncData(fetchFunction) {
const data = ref(null)
const loading = ref(false)
const error = ref(null)
const isLoading = computed(() => loading.value)
const hasError = computed(() => !!error.value)
const hasData = computed(() => !!data.value)
async function execute(...args) {
loading.value = true
error.value = null
try {
data.value = await fetchFunction(...args)
return data.value
} catch (err) {
error.value = err
throw err
} finally {
loading.value = false
}
}
function reset() {
data.value = null
loading.value = false
error.value = null
}
return {
data,
loading,
error,
isLoading,
hasError,
hasData,
execute,
reset
}
}
// Usage in component
import { useAsyncData } from '@/composables/useAsyncData'
import { fetchUser } from '@/api/users'
const { data: user, loading, error, execute: fetchUserData } = useAsyncData(fetchUser)
// Fetch data
watch(() => route.params.id, fetchUserData, { immediate: true })Fetch Before Navigation
Using Navigation Guards
javascript
// In component (Options API)
export default {
data() {
return {
user: null,
error: null
}
},
async beforeRouteEnter(to, from, next) {
try {
const user = await fetchUser(to.params.id)
next(vm => vm.setUser(user))
} catch (error) {
next(vm => vm.setError(error))
}
},
async beforeRouteUpdate(to, from) {
this.user = null
this.error = null
try {
this.user = await fetchUser(to.params.id)
} catch (error) {
this.error = error
}
},
methods: {
setUser(user) {
this.user = user
},
setError(error) {
this.error = error
}
}
}Using Global Guards
javascript
// router/index.js
router.beforeResolve(async (to) => {
// Check if route requires data preloading
if (to.meta.preloadData) {
try {
// Show global loading indicator
showGlobalLoader()
const data = await to.meta.preloadData(to.params)
// Store data in route meta for component access
to.meta.data = data
} catch (error) {
// Handle preload errors
console.error('Data preload failed:', error)
// Optionally redirect to error page
return { name: 'Error', params: { error: error.message } }
} finally {
hideGlobalLoader()
}
}
})
// Route configuration
const routes = [
{
path: '/user/:id',
component: UserProfile,
meta: {
preloadData: async (params) => {
return await fetchUser(params.id)
}
}
}
]
// In component
export default {
data() {
return {
user: null
}
},
created() {
// Access preloaded data
this.user = this.$route.meta.data
}
}Composition API with Guards
vue
<script setup>
import { ref, onMounted } from 'vue'
import { onBeforeRouteUpdate } from 'vue-router'
import { useRoute } from 'vue-router'
const route = useRoute()
const user = ref(null)
const loading = ref(false)
// Handle route updates
onBeforeRouteUpdate(async (to, from) => {
if (to.params.id !== from.params.id) {
loading.value = true
try {
user.value = await fetchUser(to.params.id)
} catch (error) {
console.error('Failed to fetch user:', error)
// Handle error appropriately
} finally {
loading.value = false
}
}
})
// Initial load
onMounted(async () => {
if (route.params.id) {
loading.value = true
try {
user.value = await fetchUser(route.params.id)
} catch (error) {
console.error('Failed to fetch user:', error)
} finally {
loading.value = false
}
}
})
</script>Advanced Patterns
Parallel Data Fetching
vue
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const user = ref(null)
const posts = ref([])
const followers = ref([])
const loading = ref({
user: false,
posts: false,
followers: false
})
watch(
() => route.params.id,
async (userId) => {
if (!userId) return
// Reset data
user.value = null
posts.value = []
followers.value = []
// Fetch data in parallel
const promises = [
fetchUserData(userId),
fetchUserPosts(userId),
fetchUserFollowers(userId)
]
// Wait for all to complete
await Promise.allSettled(promises)
},
{ immediate: true }
)
async function fetchUserData(userId) {
loading.value.user = true
try {
user.value = await fetchUser(userId)
} catch (error) {
console.error('Failed to fetch user:', error)
} finally {
loading.value.user = false
}
}
async function fetchUserPosts(userId) {
loading.value.posts = true
try {
posts.value = await fetchPosts(userId)
} catch (error) {
console.error('Failed to fetch posts:', error)
} finally {
loading.value.posts = false
}
}
async function fetchUserFollowers(userId) {
loading.value.followers = true
try {
followers.value = await fetchFollowers(userId)
} catch (error) {
console.error('Failed to fetch followers:', error)
} finally {
loading.value.followers = false
}
}
</script>
<template>
<div class="user-dashboard">
<!-- User Info Section -->
<section class="user-info">
<div v-if="loading.user" class="loading">Loading user...</div>
<UserCard v-else-if="user" :user="user" />
</section>
<!-- Posts Section -->
<section class="user-posts">
<h2>Posts</h2>
<div v-if="loading.posts" class="loading">Loading posts...</div>
<PostList v-else :posts="posts" />
</section>
<!-- Followers Section -->
<section class="user-followers">
<h2>Followers</h2>
<div v-if="loading.followers" class="loading">Loading followers...</div>
<FollowerList v-else :followers="followers" />
</section>
</div>
</template>Progressive Data Loading
vue
<script setup>
import { ref, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const user = ref(null)
const posts = ref([])
const loadingStage = ref('user') // 'user' | 'posts' | 'complete'
watch(
() => route.params.id,
async (userId) => {
if (!userId) return
// Stage 1: Load critical user data first
loadingStage.value = 'user'
user.value = null
posts.value = []
try {
user.value = await fetchUser(userId)
// Wait for DOM update before loading secondary data
await nextTick()
// Stage 2: Load secondary data
loadingStage.value = 'posts'
posts.value = await fetchUserPosts(userId)
// Stage 3: Complete
loadingStage.value = 'complete'
} catch (error) {
console.error('Failed to load data:', error)
}
},
{ immediate: true }
)
</script>
<template>
<div class="progressive-loader">
<!-- Always show user section -->
<section class="user-section">
<div v-if="loadingStage === 'user'" class="loading">
Loading profile...
</div>
<UserProfile v-else-if="user" :user="user" />
</section>
<!-- Show posts section after user loads -->
<section v-if="loadingStage !== 'user'" class="posts-section">
<h2>Recent Posts</h2>
<div v-if="loadingStage === 'posts'" class="loading">
Loading posts...
</div>
<PostList v-else :posts="posts" />
</section>
</div>
</template>Data Caching
javascript
// utils/dataCache.js
class DataCache {
constructor(ttl = 5 * 60 * 1000) { // 5 minutes default TTL
this.cache = new Map()
this.ttl = ttl
}
set(key, data) {
this.cache.set(key, {
data,
timestamp: Date.now()
})
}
get(key) {
const item = this.cache.get(key)
if (!item) return null
// Check if expired
if (Date.now() - item.timestamp > this.ttl) {
this.cache.delete(key)
return null
}
return item.data
}
has(key) {
return this.get(key) !== null
}
clear() {
this.cache.clear()
}
delete(key) {
this.cache.delete(key)
}
}
export const userCache = new DataCache()
// Enhanced fetch function with caching
export async function fetchUserWithCache(userId) {
const cacheKey = `user:${userId}`
// Check cache first
const cachedUser = userCache.get(cacheKey)
if (cachedUser) {
return cachedUser
}
// Fetch from API
const user = await fetchUser(userId)
// Cache the result
userCache.set(cacheKey, user)
return user
}Error Handling and Retry
vue
<script setup>
import { ref, computed } from 'vue'
const data = ref(null)
const loading = ref(false)
const error = ref(null)
const retryCount = ref(0)
const maxRetries = 3
const canRetry = computed(() => retryCount.value < maxRetries)
const isRetrying = computed(() => loading.value && retryCount.value > 0)
async function fetchData(userId, isRetry = false) {
if (isRetry) {
retryCount.value++
} else {
retryCount.value = 0
}
loading.value = true
error.value = null
try {
data.value = await fetchUser(userId)
retryCount.value = 0 // Reset on success
} catch (err) {
error.value = err
// Auto-retry for network errors
if (canRetry.value && isNetworkError(err)) {
setTimeout(() => {
fetchData(userId, true)
}, Math.pow(2, retryCount.value) * 1000) // Exponential backoff
}
} finally {
loading.value = false
}
}
function manualRetry() {
if (canRetry.value) {
fetchData(route.params.id, true)
}
}
function isNetworkError(error) {
return error.code === 'NETWORK_ERROR' ||
error.message.includes('fetch')
}
</script>
<template>
<div class="data-container">
<div v-if="loading && !isRetrying" class="loading">
Loading...
</div>
<div v-else-if="isRetrying" class="loading">
Retrying... ({{ retryCount }}/{{ maxRetries }})
</div>
<div v-else-if="error" class="error">
<h3>Failed to load data</h3>
<p>{{ error.message }}</p>
<button
v-if="canRetry"
@click="manualRetry"
class="retry-button"
>
Retry ({{ maxRetries - retryCount }} attempts left)
</button>
<p v-else class="max-retries">
Maximum retry attempts reached. Please refresh the page.
</p>
</div>
<div v-else-if="data" class="content">
<!-- Render your data here -->
</div>
</div>
</template>Performance Optimization
Lazy Loading with Intersection Observer
vue
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const container = ref(null)
const posts = ref([])
const loading = ref(false)
const hasMore = ref(true)
const page = ref(1)
let observer = null
onMounted(() => {
// Load initial data
loadPosts()
// Set up intersection observer for infinite scroll
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore.value && !loading.value) {
loadMorePosts()
}
},
{ threshold: 0.1 }
)
if (container.value) {
observer.observe(container.value)
}
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
}
})
async function loadPosts() {
loading.value = true
try {
const response = await fetchPosts(route.params.id, 1)
posts.value = response.data
hasMore.value = response.hasMore
page.value = 1
} catch (error) {
console.error('Failed to load posts:', error)
} finally {
loading.value = false
}
}
async function loadMorePosts() {
loading.value = true
try {
const response = await fetchPosts(route.params.id, page.value + 1)
posts.value.push(...response.data)
hasMore.value = response.hasMore
page.value++
} catch (error) {
console.error('Failed to load more posts:', error)
} finally {
loading.value = false
}
}
</script>
<template>
<div class="posts-container">
<PostCard
v-for="post in posts"
:key="post.id"
:post="post"
/>
<div
ref="container"
class="load-more-trigger"
>
<div v-if="loading" class="loading">
Loading more posts...
</div>
<div v-else-if="!hasMore" class="end-message">
No more posts to load
</div>
</div>
</div>
</template>Prefetching Data
javascript
// router/index.js
import { prefetchUserData } from '@/utils/prefetch'
const routes = [
{
path: '/user/:id',
component: UserProfile,
meta: {
prefetch: true
}
}
]
// Prefetch data on route hover
router.beforeEach((to, from, next) => {
if (to.meta.prefetch) {
// Start prefetching but don't wait for it
prefetchUserData(to.params.id).catch(console.error)
}
next()
})
// utils/prefetch.js
const prefetchCache = new Map()
export async function prefetchUserData(userId) {
const cacheKey = `user:${userId}`
if (prefetchCache.has(cacheKey)) {
return prefetchCache.get(cacheKey)
}
const promise = fetchUser(userId)
prefetchCache.set(cacheKey, promise)
try {
const user = await promise
// Store in main cache
userCache.set(cacheKey, user)
return user
} catch (error) {
// Remove failed promise from cache
prefetchCache.delete(cacheKey)
throw error
}
}
// Link prefetching on hover
export function useLinkPrefetch() {
function prefetchOnHover(event) {
const link = event.target.closest('a')
if (!link) return
const href = link.getAttribute('href')
if (href && href.startsWith('/user/')) {
const userId = href.split('/')[2]
prefetchUserData(userId)
}
}
return { prefetchOnHover }
}Testing Data Fetching
Unit Testing
javascript
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import UserProfile from '@/components/UserProfile.vue'
// Mock API
jest.mock('@/api/users', () => ({
fetchUser: jest.fn()
}))
describe('UserProfile Data Fetching', () => {
let router
let wrapper
beforeEach(() => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/user/:id', component: UserProfile }
]
})
})
afterEach(() => {
wrapper?.unmount()
})
it('shows loading state while fetching data', async () => {
const { fetchUser } = require('@/api/users')
// Mock delayed response
fetchUser.mockImplementation(() =>
new Promise(resolve => setTimeout(() => resolve(mockUser), 100))
)
await router.push('/user/123')
wrapper = mount(UserProfile, {
global: {
plugins: [router]
}
})
// Should show loading initially
expect(wrapper.find('.loading').exists()).toBe(true)
expect(wrapper.find('.profile-content').exists()).toBe(false)
// Wait for data to load
await new Promise(resolve => setTimeout(resolve, 150))
await wrapper.vm.$nextTick()
// Should show content after loading
expect(wrapper.find('.loading').exists()).toBe(false)
expect(wrapper.find('.profile-content').exists()).toBe(true)
})
it('handles fetch errors gracefully', async () => {
const { fetchUser } = require('@/api/users')
fetchUser.mockRejectedValue(new Error('Network error'))
await router.push('/user/123')
wrapper = mount(UserProfile, {
global: {
plugins: [router]
}
})
await wrapper.vm.$nextTick()
expect(wrapper.find('.error-container').exists()).toBe(true)
expect(wrapper.text()).toContain('Network error')
})
})Integration Testing
javascript
// Cypress example
describe('Data Fetching', () => {
beforeEach(() => {
// Mock API responses
cy.intercept('GET', '/api/users/*', { fixture: 'user.json' }).as('getUser')
cy.intercept('GET', '/api/users/*/posts', { fixture: 'posts.json' }).as('getPosts')
})
it('loads user data on route navigation', () => {
cy.visit('/user/123')
// Should show loading state
cy.contains('Loading user profile...')
// Wait for API call
cy.wait('@getUser')
// Should show user data
cy.contains('John Doe')
cy.contains('Software Developer')
})
it('handles network errors', () => {
cy.intercept('GET', '/api/users/*', { statusCode: 500 }).as('getUserError')
cy.visit('/user/123')
cy.wait('@getUserError')
cy.contains('Failed to load profile')
cy.get('[data-testid="retry-button"]').should('be.visible')
})
})Best Practices
1. Choose the Right Strategy
javascript
// ✅ Fetch after navigation for non-critical data
const UserDashboard = {
async created() {
// Load dashboard data after component mounts
this.stats = await fetchUserStats(this.$route.params.id)
}
}
// ✅ Fetch before navigation for critical data
const UserProfile = {
async beforeRouteEnter(to, from, next) {
// Load essential user data before showing component
const user = await fetchUser(to.params.id)
next(vm => vm.user = user)
}
}2. Provide Good Loading States
vue
<template>
<div class="content-container">
<!-- Skeleton loading for better UX -->
<div v-if="loading" class="skeleton">
<div class="skeleton-avatar"></div>
<div class="skeleton-text"></div>
<div class="skeleton-text short"></div>
</div>
<!-- Actual content -->
<div v-else class="content">
<!-- Your content here -->
</div>
</div>
</template>
<style>
.skeleton-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>3. Handle Errors Gracefully
javascript
// Global error handler
app.config.errorHandler = (error, instance, info) => {
if (info === 'data-fetch') {
// Handle data fetching errors specifically
showNotification('Failed to load data. Please try again.', 'error')
}
}
// In components
async function fetchData() {
try {
return await api.fetchUser(userId)
} catch (error) {
// Log error for debugging
console.error('Data fetch failed:', error)
// Show user-friendly message
if (error.status === 404) {
throw new Error('User not found')
} else if (error.status >= 500) {
throw new Error('Server error. Please try again later.')
} else {
throw new Error('Failed to load data')
}
}
}4. Optimize Performance
javascript
// Debounce search requests
import { debounce } from 'lodash-es'
const debouncedSearch = debounce(async (query) => {
if (query.length < 2) return
try {
const results = await searchUsers(query)
searchResults.value = results
} catch (error) {
console.error('Search failed:', error)
}
}, 300)
// Cache frequently accessed data
const userCache = new Map()
async function getCachedUser(userId) {
if (userCache.has(userId)) {
return userCache.get(userId)
}
const user = await fetchUser(userId)
userCache.set(userId, user)
return user
}Next Steps
- Learn about Lazy Loading Routes for code splitting
- Explore Route Meta Fields for storing route-specific data
- Understand Scroll Behavior for better navigation UX