Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 096 / 100

Vue Router data loading: real-world lessons and examples

Load data before showing pages.

Expona Admin8 min read

Vue Router data loading: real-world lessons and examples

Data loading in single-page applications is deceptively complex—get it wrong and you'll show users blank screens, stale data, or UI that breaks when navigation happens mid-fetch. Vue Router data loading: real-world lessons and examples covers the practical patterns we've refined across production e-commerce, SaaS, and trading platforms where reliability matters. In this post, we'll walk through the core strategies teams use to load data before showing pages, handle loading states properly, and cancel requests when users navigate away.

Fetch in route guards or components

The first decision is where to fetch: inside route guards (before the component renders) or inside the component itself. Each approach has tradeoffs, and the right choice depends on whether you need data to gate access to the route or just enhance the view.

Route guards let you fetch data before the component mounts. This is ideal when the data is essential—like checking user permissions, fetching a specific record that must exist, or loading critical setup state. If the fetch fails, you can redirect or show an error boundary.

typescript
// router.ts
router.beforeEach(async (to, from, next) => {
  if (to.meta.requiresData) {
    try {
      const data = await fetchPageData(to.params.id);
      to.meta.data = data; // store on route meta
      next();
    } catch (error) {
      next({ name: 'ErrorPage', params: { code: 404 } });
    }
  } else {
    next();
  }
});

The downside is that route guards block navigation—users see no visual feedback while the fetch runs. For slower networks, this feels broken.

Component-level fetching starts loading data in setup() or inside a onMounted() hook. The route transition completes immediately, and you show a loading skeleton or spinner while the data arrives. This is more user-friendly for non-critical data.

typescript
// ProductPage.vue
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';

export default {
  setup() {
    const route = useRoute();
    const product = ref(null);
    const loading = ref(true);
    const error = ref(null);

    onMounted(async () => {
      try {
        const response = await fetch(`/api/products/${route.params.id}`);
        if (!response.ok) throw new Error('Product not found');
        product.value = await response.json();
      } catch (err) {
        error.value = err.message;
      } finally {
        loading.value = false;
      }
    });

    return { product, loading, error };
  }
};

In practice, we often hybrid approach: use guards for auth checks or redirect logic, and fetch content in components for faster perceived performance.

Show loading UI during navigation

Users need feedback. A blank page or stale content while data loads creates anxiety and increases perceived latency. Real-world apps show skeletons, spinners, or progressive content.

For route transitions, use the router's isNavigating state or a custom loading flag. We've found skeletons more effective than spinners because they occupy the same layout space as the final content, preventing layout shift.

typescript
// App.vue
import { useRouter } from 'vue-router';
import { ref, watch } from 'vue';

export default {
  setup() {
    const router = useRouter();
    const isNavigating = ref(false);

    watch(() => router.currentRoute.value, () => {
      isNavigating.value = false;
    });

    router.beforeEach(() => {
      isNavigating.value = true;
    });

    return { isNavigating };
  }
};

Then in your layout or app shell:

typescript
// App.vue template
<div class="page-container">
  <Suspense>
    <template #default>
      <router-view />
    </template>
    <template #fallback>
      <PageSkeleton />
    </template>
  </Suspense>
</div>

For in-component loading, render conditional UI based on the loading flag. Avoid layout thrashing by using the same grid or flex container for both skeleton and content.

typescript
// ProductPage.vue
<template>
  <div v-if="loading" class="product-skeleton">
    <div class="skeleton-image"></div>
    <div class="skeleton-text"></div>
  </div>
  <div v-else-if="error" class="error-message">
    Failed to load: {{ error }}
  </div>
  <div v-else class="product">
    <img :src="product.image" />
    <h1>{{ product.name }}</h1>
  </div>
</template>

Cancel on navigation changes

When a user navigates away mid-fetch, the promise still resolves. If you're not careful, you'll update state on an unmounted component, causing memory leaks, race conditions, and subtle bugs.

AbortController is the standard way to cancel fetch requests. Pass the abort signal to your fetch call, and abort it when the component unmounts or the route changes.

typescript
// ProductPage.vue
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { useRoute } from 'vue-router';

export default {
  setup() {
    const route = useRoute();
    const product = ref(null);
    const loading = ref(true);
    const abortController = ref(null);

    const fetchProduct = async () => {
      abortController.value = new AbortController();
      try {
        const response = await fetch(`/api/products/${route.params.id}`, {
          signal: abortController.value.signal
        });
        if (!response.ok) throw new Error('Not found');
        product.value = await response.json();
      } catch (err) {
        // AbortError is thrown when request is cancelled
        if (err.name !== 'AbortError') {
          console.error('Fetch failed:', err);
        }
      } finally {
        loading.value = false;
      }
    };

    onMounted(() => fetchProduct());

    onBeforeUnmount(() => {
      abortController.value?.abort();
    });

    return { product, loading };
  }
};

For HTTP clients like axios, pass the cancel token instead:

typescript
import axios from 'axios';

const source = axios.CancelToken.source();

try {
  const { data } = await axios.get('/api/products/1', {
    cancelToken: source.token
  });
} catch (err) {
  if (axios.isCancel(err)) {
    console.log('Request cancelled');
  }
}

// Cancel on unmount
onBeforeUnmount(() => source.cancel('Component unmounted'));

Without cancellation, you risk updating state on unmounted components. In development, this causes console warnings. In production, it's silent data corruption.

Nuxt handles this with useFetch

If you're using Nuxt, useFetch and useLazyFetch abstractions handle cancellation, server-side rendering, and state management automatically. This is one reason why Nuxt-based projects often have fewer data-loading bugs.

typescript
// Nuxt page
import { useFetch } from '#app';

export default definePageComponent({
  setup() {
    const { data: product, pending, error, refresh } = useFetch(() =>
      `/api/products/${useRoute().params.id}`
    );

    return { product, pending, error, refresh };
  }
});

With useFetch, Nuxt:

  • Runs the fetch on the server first (avoiding content shift)
  • Cancels automatically when the component unmounts
  • Handles watch dependencies so re-fetches happen when route params change
  • Provides a refresh method for manual refetching

useLazyFetch is the lazy variant—it runs only in the browser and returns pending data immediately, letting you show a loading state while the fetch runs.

typescript
// Nuxt page with lazy fetch
const { data: comments, pending } = useLazyFetch(() =>
  `/api/posts/${route.params.id}/comments`
);

The trade-off: you won't have comments on the server-rendered page, but the page renders faster and you get a better loading experience. Choose based on whether the data is critical for SEO or user experience.

Common mistakes

Fetching without cancellation: The most common bug. A user clicks a link, the previous component's fetch resolves after the new page loads, and state updates on an unmounted component. Always use AbortController or rely on framework abstractions.

Blocking navigation on slow data: Route guards that fetch slow data trap the user. If the data isn't critical for access, fetch in the component and show a skeleton. Guard fetches should time out or fail fast.

Not handling loading states: Showing blank pages or stale content is disorienting. Always render loading UI while data is in flight, even if it's just a subtle opacity change.

Fetching in watchers without cleanup: Watching route params to refetch is intuitive, but if you don't clean up the previous request, you can get race conditions where newer data is overwritten by older responses.

typescript
// DON'T: Race condition risk
watch(() => route.params.id, async () => {
  product.value = await fetchProduct(route.params.id);
});

// DO: Cancel previous fetch
watch(() => route.params.id, async () => {
  abortController.value?.abort();
  abortController.value = new AbortController();
  product.value = await fetchProduct(route.params.id, abortController.value.signal);
});

Not displaying errors: Network failures happen. If you silently fail, users think the page is broken. Always show an error message and a retry button.

Key takeaways

  • Fetch in route guards if the data gates access; fetch in components if it's enhancement content. Guards block navigation, so keep them fast.
  • Always show loading UI—skeletons are better than spinners because they prevent layout shift.
  • Cancel requests on component unmount or navigation using AbortController or framework equivalents. Updating state on unmounted components causes bugs.
  • Nuxt's useFetch and useLazyFetch handle cancellation and SSR automatically—use them if you're on Nuxt.
  • Handle errors visibly and let users retry. Silent failures erode trust.
  • Watch route params carefully—without proper cancellation, you can get race conditions where old responses overwrite new ones.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles