Skip to content

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

🚀 Vue Router - 让前端路由变得简单而强大 | 构建现代化单页应用的最佳选择