Data Fetching con Vue 3 Composition API
Cómo fetchear y manejar data en Vue 3 usando la Composition API con ref, computed, watch y composables para lógica de data reutilizable.
Overview
La Composition API de Vue 3 provee ref, reactive, computed y watch para manejar estado y side effects. Para data fetching, combinas estos con onMounted para cargar data cuando el componente se inicializa. Extraer la lógica de fetch en una composable function la hace reutilizable a través de componentes — similar a los hooks de React pero con el sistema de reactividad de Vue.
When to Use
- Cargar data de una API cuando un componente se monta
- Reutilizar lógica de data-fetching a través de múltiples componentes (user data, settings, feature flags)
- Data reactiva que depende de otros valores reactivos (fetch cuando cambia un filter)
- Paginación, búsqueda o cualquier escenario donde re-fetcheas basado en user input
When NOT to Use
- Data estática hardcodeada en el componente — no necesita lógica de fetch
- Global state management — usa Pinia para estado compartido a través de la app
- Server-side rendering con Nuxt — usa
useAsyncDataouseFetchde Nuxt en su lugar
Solution
Fetch básico en 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>
Extraer en un 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>
Fetch reactivo con 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 cuando query cambia (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>
Composable de fetch genérico
// 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>
Composable de paginación
// 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 };
}
Usar con 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
Usar con 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>
Usar con axios e 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.
-
Extrae la lógica de fetch en composables — no escribas llamadas
fetch()directamente en componentes -
Siempre maneja estados de loading y error — los usuarios necesitan feedback durante y después de fetches
-
Usa
watchconimmediate: truepara fetcheear en mount y cuando cambian las dependencias -
Debouncea inputs de búsqueda — no fetchees en cada keystroke
-
Usa
unref()cuando aceptas tanto ref como non-ref arguments en composables -
Limpia timeouts y subscriptions en
onUnmounted— previene memory leaks -
Usa
computedpara estado derivado — no dupliques data en múltiples refs
Common Mistakes
- Olvidar manejar el estado de error: la UI muestra “loading” para siempre si el fetch falla.
- No debouncear la búsqueda: cada keystroke dispara un fetch, abrumando la API.
- Usar
reactivepara valores primitivos:reactive(0)no funciona — usaref(0). - No limpiar watchers: un
watchcreado dentro de un composable que se llama fuera desetupleakea. UsaonScopeDisposeo retorna la stop function. - Fetcheear en
setupsinonMounted:setupcorre durante la creación del componente — usaonMountedpara fetches solo de browser.
Preguntas frecuentes
¿Cuál es la diferencia entre ref y reactive?
ref envuelve cualquier valor (incluyendo primitivos) en un reactive object. reactive hace un objeto existente reactivo. Usa ref para primitivos y reactive para objetos. Accede a valores de ref con .value en script, se auto-unwrappean en templates.
¿Puedo usar async/await en setup?
Solo con <script setup> y top-level await — esto hace el componente async y requiere <Suspense> en el parent. De lo contrario, usa onMounted con callbacks async.
¿Cómo cancelo un fetch cuando el componente se desmonta?
import { onUnmounted } from "vue";
const controller = new AbortController();
onMounted(() => {
fetch("/api/data", { signal: controller.signal })
.then((r) => r.json())
.then(setData);
});
onUnmounted(() => controller.abort());
¿Debería usar composables o Pinia para data fetching?
Usa composables para data local del componente. Usa Pinia para data compartida a través de múltiples componentes o cuando necesitas devtools, persistencia o acciones cross-component.
¿Cómo testeo un composable?
Usa @vue/test-utils con mount o shallowMount. Para composables que usan lifecycle hooks, llámalos dentro del setup de un componente:
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);
});
Recursos Relacionados
Validación de Formularios con react-hook-form y Zod
Construí formularios type-safe en React usando react-hook-form con validación de esquemas Zod, incluyendo campos anidados, reglas async, campos dinámicos e integración con librerías de UI.
RecipeCuándo Usar useMemo y useCallback
Cómo y cuándo usar los hooks useMemo y useCallback de React para optimización de performance, y cuándo añaden overhead innecesario.
RecipeContainer Queries para Responsiveness de Componentes
Cómo usar CSS container queries para layouts responsivos a nivel de componente que se adaptan al tamaño de su contenedor en lugar del viewport.
RecipeState Management Reactivo con Svelte Stores
Cómo manejar estado reactivo en Svelte usando writable, readable, derived stores y custom stores con actualizaciones basadas en contrato.