Most Vue apps load data from APIs. Handle async operations with lifecycle hooks, composables, and clear loading/error states.

Fetch on Mount

  <script setup>
import { ref, onMounted } from 'vue';

const posts = ref([]);
const loading = ref(true);
const error = ref(null);

onMounted(async () => {
  try {
    const res = await fetch('https://jsonplaceholder.typicode.com/posts');
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    posts.value = await res.json();
  } catch (err) {
    error.value = err.message;
  } finally {
    loading.value = false;
  }
});
</script>

<template>
  <p v-if="loading">Loading posts...</p>
  <p v-else-if="error" class="error">{{ error }}</p>
  <ul v-else>
    <li v-for="post in posts.slice(0, 10)" :key="post.id">
      {{ post.title }}
    </li>
  </ul>
</template>
  

Reactive Fetch with watch

Refetch when route params or filters change:

  <script setup>
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';

const route = useRoute();
const user = ref(null);
const loading = ref(false);

watch(
  () => route.params.id,
  async (id) => {
    if (!id) return;
    loading.value = true;
    user.value = null;
    try {
      const res = await fetch(`/api/users/${id}`);
      user.value = await res.json();
    } finally {
      loading.value = false;
    }
  },
  { immediate: true }
);
</script>
  

useFetch Composable

Extract fetch logic for reuse:

  // composables/useFetch.js
import { ref, watchEffect, toValue } from 'vue';

export function useFetch(url) {
  const data = ref(null);
  const error = ref(null);
  const loading = ref(true);

  watchEffect(async (onCleanup) => {
    const endpoint = toValue(url);
    loading.value = true;
    error.value = null;
    data.value = null;

    const controller = new AbortController();
    onCleanup(() => controller.abort());

    try {
      const res = await fetch(endpoint, { signal: controller.signal });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      data.value = await res.json();
    } catch (err) {
      if (err.name !== 'AbortError') error.value = err.message;
    } finally {
      loading.value = false;
    }
  });

  return { data, error, loading };
}
  
  <script setup>
import { useFetch } from '../composables/useFetch';

const { data: posts, error, loading } = useFetch(
  'https://jsonplaceholder.typicode.com/posts'
);
</script>
  

Best Practices

  • Always handle loading, error, and empty states in the UI
  • Abort requests on unmount or when dependencies change
  • Avoid fetching inside computed — use watch or composables
  • Consider TanStack Query for Vue for caching and deduplication in larger apps

Next: test your components with Vitest and Vue Test Utils.