Data Fetching with Vue 3 Composition API
How to fetch and manage data in Vue 3 using the Composition API with ref, computed, watch, and composables for reusable data logic.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
Vue 3’s Composition API provides ref, reactive, computed, and watch for managing state and side effects. For data fetching, you combine these with onMounted to load data when the component initializes. Extracting the fetch logic into a composable function makes it reusable across components — similar to React hooks but with Vue’s reactivity system.
When to Use
- Loading data from an API when a component mounts
- Reusing data-fetching logic across multiple components (user data, settings, feature flags)
- Reactive data that depends on other reactive values (fetch when filter changes)
- Pagination, search, or any scenario where you re-fetch based on user input
When NOT to Use
- Static data hardcoded in the component — no need for fetch logic
- Global state management — use Pinia for shared state across the app
- Server-side rendering with Nuxt — use
useAsyncDataoruseFetchfrom Nuxt instead
Solution
Basic data fetch on mount
<script setup>
import { ref, onMounted } from "vue";
const users = ref([]);
const loading = ref(true);
const error = ref(null);
onMounted(async () => {
try {
loading.value = true;
const response = await fetch("/api/users");
users.value = await response.json();
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
});
</script>
<template>
<div>
<p v-if="loading">Loading...</p>
<p v-else-if="error">Error: {{ error }}</p>
<ul v-else>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
</div>
</template>
Extracting into a composable
// composables/useUsers.js
import { ref } from "vue";
export function useUsers() {
const users = ref([]);
const loading = ref(false);
const error = ref(null);
async function fetchUsers() {
loading.value = true;
error.value = null;
try {
const response = await fetch("/api/users");
if (!response.ok) throw new Error("Failed to fetch users");
users.value = await response.json();
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
return { users, loading, error, fetchUsers };
}
<script setup>
import { onMounted } from "vue";
import { useUsers } from "@/composables/useUsers";
const { users, loading, error, fetchUsers } = useUsers();
onMounted(fetchUsers);
</script>
<template>
<div>
<p v-if="loading">Loading...</p>
<p v-else-if="error">{{ error }}</p>
<ul v-else>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
</div>
</template>
Reactive fetch with watch
// composables/useSearch.js
import { ref, watch } from "vue";
export function useSearch(initialQuery = "") {
const query = ref(initialQuery);
const results = ref([]);
const loading = ref(false);
const error = ref(null);
async function search() {
if (!query.value.trim()) {
results.value = [];
return;
}
loading.value = true;
error.value = null;
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query.value)}`);
results.value = await response.json();
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
// Re-fetch when query changes (debounced)
let timeout;
watch(query, () => {
clearTimeout(timeout);
timeout = setTimeout(search, 300);
});
return { query, results, loading, error, search };
}
<script setup>
import { useSearch } from "@/composables/useSearch";
const { query, results, loading, error } = useSearch();
</script>
<template>
<div>
<input v-model="query" placeholder="Search..." />
<p v-if="loading">Searching...</p>
<p v-else-if="error">{{ error }}</p>
<ul v-else>
<li v-for="result in results" :key="result.id">{{ result.title }}</li>
</ul>
</div>
</template>
Generic fetch composable
// composables/useFetch.js
import { ref, watch, isRef, unref } from "vue";
export function useFetch(url, options = {}) {
const data = ref(null);
const error = ref(null);
const loading = ref(false);
async function doFetch() {
loading.value = true;
error.value = null;
try {
const resolvedUrl = unref(url);
const response = await fetch(resolvedUrl, options);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
data.value = await response.json();
} catch (err) {
error.value = err.message;
data.value = null;
} finally {
loading.value = false;
}
}
if (isRef(url)) {
watch(url, doFetch, { immediate: true });
} else {
doFetch();
}
return { data, error, loading, refresh: doFetch };
}
<script setup>
import { ref } from "vue";
import { useFetch } from "@/composables/useFetch";
const userId = ref(1);
const { data: user, error, loading, refresh } = useFetch(
() => `/api/users/${userId.value}`
);
</script>
<template>
<div>
<button @click="userId--">Previous</button>
<span>User #{{ userId }}</span>
<button @click="userId++">Next</button>
<p v-if="loading">Loading...</p>
<p v-else-if="error">{{ error }}</p>
<div v-else-if="user">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
</div>
<button @click="refresh">Refresh</button>
</div>
</template>
Pagination composable
// composables/usePagination.js
import { ref, computed, watch } from "vue";
export function usePagination(fetchFn, perPage = 10) {
const page = ref(1);
const total = ref(0);
const items = ref([]);
const loading = ref(false);
const totalPages = computed(() => Math.ceil(total.value / perPage));
const hasNext = computed(() => page.value < totalPages.value);
const hasPrev = computed(() => page.value > 1);
async function loadPage() {
loading.value = true;
try {
const result = await fetchFn(page.value, perPage);
items.value = result.items;
total.value = result.total;
} finally {
loading.value = false;
}
}
function next() {
if (hasNext.value) {
page.value++;
loadPage();
}
}
function prev() {
if (hasPrev.value) {
page.value--;
loadPage();
}
}
function goTo(p) {
page.value = p;
loadPage();
}
watch(page, loadPage, { immediate: true });
return { page, items, total, totalPages, hasNext, hasPrev, loading, next, prev, goTo };
}
Using with async setup (Suspense)
<!-- UserCard.vue -->
<script setup>
import { ref } from "vue";
const props = defineProps(["userId"]);
const user = ref(
await fetch(`/api/users/${props.userId}`).then((r) => r.json())
);
</script>
<template>
<div>
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
</div>
</template>
<!-- Parent.vue -->
<script setup>
import { Suspense } from "vue";
import UserCard from "./UserCard.vue";
</script>
<template>
<Suspense>
<template #default>
<UserCard :userId="1" />
</template>
<template #fallback>
<p>Loading user...</p>
</template>
</Suspense>
</template>
Variants
Using with Pinia store
// stores/userStore.js
import { defineStore } from "pinia";
import { ref } from "vue";
export const useUserStore = defineStore("user", () => {
const user = ref(null);
const loading = ref(false);
async function fetchUser(id) {
loading.value = true;
try {
const response = await fetch(`/api/users/${id}`);
user.value = await response.json();
} finally {
loading.value = false;
}
}
return { user, loading, fetchUser };
});
<script setup>
import { onMounted } from "vue";
import { useUserStore } from "@/stores/userStore";
const userStore = useUserStore();
onMounted(() => userStore.fetchUser(1));
</script>
Using with axios and interceptors
// composables/useApi.js
import { ref } from "vue";
import axios from "axios";
const api = axios.create({ baseURL: "/api" });
api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
export function useApi(url) {
const data = ref(null);
const error = ref(null);
const loading = ref(false);
async function fetch() {
loading.value = true;
try {
const response = await api.get(url);
data.value = response.data;
} catch (err) {
error.value = err.response?.data?.message || err.message;
} finally {
loading.value = false;
}
}
return { data, error, loading, fetch };
}
Best Practices
-
For a deeper guide, see Container Queries for Component Responsiveness.
-
Extract fetch logic into composables — don’t write
fetch()calls directly in components -
Always handle loading and error states — users need feedback during and after fetches
-
Use
watchwithimmediate: trueto fetch on mount and when dependencies change -
Debounce search inputs — don’t fetch on every keystroke
-
Use
unref()when accepting both ref and non-ref arguments in composables -
Clean up timeouts and subscriptions in
onUnmounted— prevent memory leaks -
Use
computedfor derived state — don’t duplicate data in multiple refs
Common Mistakes
- Forgetting to handle error state: the UI shows “loading” forever if the fetch fails.
- Not debouncing search: every keystroke triggers a fetch, overwhelming the API.
- Using
reactivefor primitive values:reactive(0)doesn’t work — useref(0). - Not cleaning up watchers:
watchcreated inside a composable that’s called outsidesetupleaks. UseonScopeDisposeor return the stop function. - Fetching in
setupwithoutonMounted:setupruns during component creation — useonMountedfor browser-only fetches.
FAQ
What is the difference between ref and reactive?
ref wraps any value (including primitives) in a reactive object. reactive makes an existing object reactive. Use ref for primitives and reactive for objects. Access ref values with .value in script, they’re auto-unwrapped in templates.
Can I use async/await in setup?
Only with <script setup> and top-level await — this makes the component async and requires <Suspense> in the parent. Otherwise, use onMounted with async callbacks.
How do I cancel a fetch when the component unmounts?
import { onUnmounted } from "vue";
const controller = new AbortController();
onMounted(() => {
fetch("/api/data", { signal: controller.signal })
.then((r) => r.json())
.then(setData);
});
onUnmounted(() => controller.abort());
Should I use composables or Pinia for data fetching?
Use composables for component-local data. Use Pinia for data shared across multiple components or when you need devtools, persistence, or cross-component actions.
How do I test a composable?
Use @vue/test-utils with mount or shallowMount. For composables that use lifecycle hooks, call them inside a component’s setup:
import { mount } from "@vue/test-utils";
import { useUsers } from "@/composables/useUsers";
test("useUsers fetches users", async () => {
let result;
const wrapper = mount({
setup() {
result = useUsers();
return {};
},
});
await result.fetchUsers();
expect(result.users.value.length).toBeGreaterThan(0);
}); Related Resources
Form Validation with react-hook-form and Zod
How to build type-safe forms in React using react-hook-form with Zod schema validation, including nested fields, async validation, and dynamic fields.
RecipeWhen to Use useMemo and useCallback
How and when to use React's useMemo and useCallback hooks for performance optimization, and when they add unnecessary overhead.
RecipeContainer Queries for Component Responsiveness
How to use CSS container queries for component-level responsive layouts that adapt to their container size instead of the viewport.
RecipeReactive State Management with Svelte Stores
How to manage reactive state in Svelte using writable, readable, derived stores, and custom stores with contract-based updates.