Nuxt 4: $fetch, useFetch y useAsyncData - Guía Completa de Obtención de Datos
La gestión de la obtención de datos en Nuxt está organizada en una jerarquía de tres niveles, donde cada herramienta tiene un rol arquitectónico distinto:
| Herramienta | Rol Arquitectónico | Propósito Principal | Transferencia de Estado SSR (Hidratación) |
|---|---|---|---|
| $fetch | Utilidad de Bajo Nivel (Raw HTTP Client) | Ejecución de solicitudes de red HTTP puras. | No. Si se usa directamente en componentes SSR, causa doble fetching. |
| useAsyncData | Primitiva de Estado (Core Primitive) | Administra el ciclo de vida del estado asíncrono y garantiza la transferencia de datos entre el servidor y el cliente. | Sí. Captura el resultado, lo serializa en el payload de Nuxt y evita la doble solicitud. |
| useFetch | Envoltorio de Conveniencia (Developer Wrapper) | Simplifica el caso de uso más común: obtener datos de un único endpoint HTTP. | Sí. Funciona internamente con useAsyncData, heredando sus capacidades. |
useFetch es esencialmente azúcar sintáctico (un shortcut) para implementar useAsyncData junto con $fetch.
Cuándo es mejor usar uno u otro
La elección depende fundamentalmente del contexto arquitectónico de la operación:
1. useFetch: Recuperación Inicial de Datos Simples (GET)
Se recomienda usar useFetch para la obtención inicial de datos a través de peticiones GET de un único endpoint, especialmente cuando se necesita el soporte de Server-Side Rendering (SSR) y se quiere evitar la doble obtención de datos. Es la opción con la sintaxis más limpia.
Ejemplo Claro: Obtener una lista de artículos para una página.
// app/pages/posts.vue
<script setup lang="ts">
// Se encarga de hacer la solicitud una vez (en el servidor) y transferir el estado.
const { data: posts } = await useFetch('/api/posts')
</script>2. useAsyncData: Lógica Asíncrona Compleja o Fuentes de Datos No HTTP
Es obligatorio usar useAsyncData cuando la operación asíncrona no es una simple llamada HTTP a un endpoint. Es la herramienta principal para envolver cualquier promesa asíncrona.
Ejemplos Claros:
-
Llamadas paralelas: Orquestar múltiples llamadas
$fetchsimultáneamente y combinarlas.const { data } = await useAsyncData("dashboard", async () => { const [user, stats] = await Promise.all([$fetch("/api/user"), $fetch("/api/stats")]); return { user, stats }; }); -
Uso de clientes de terceros: Integrar clientes que no usan
$fetch, como clientes de GraphQL o un ORM de base de datos.// myGetFunction es una función personalizada de tu cliente de datos const { data } = await useAsyncData("users", () => myGetFunction("users"));
3. $fetch: Mutaciones y Acciones Impulsadas por el Cliente/Servidor
Se recomienda explícitamente usar $fetch para interacciones que son fundamentalmente del lado del cliente y son impulsadas por eventos de usuario (como clics o envíos de formularios). También es la mejor opción para llamadas de utilidad de bajo nivel que no requieren la sincronización de estado de SSR.
Ejemplos Claros:
-
Mutaciones (POST/PUT/DELETE): Enviar datos de un formulario. Usar
useFetchpara mutaciones generalmente es ineficiente ya que no se beneficia de la hidratación del estado.// Se usa dentro de un manejador de eventos (event handler) async function submitForm() { await $fetch("/api/contact", { method: "POST", body: formData.value }); } -
Llamadas a Rutas de Servidor Internas: Dentro de las rutas de la API de Nuxt (ej.
server/api),$fetchevita la solicitud HTTP y llama a la función de manejador directamente, optimizando la velocidad en el servidor.
Ventajas y Desventajas
| Herramienta | Ventajas | Desventajas |
|---|---|---|
| $fetch | Optimización de llamadas internas: Llama a las rutas de servidor internas directamente, sin un viaje de ida y vuelta HTTP (ahorro de latencia). Ideal para mutaciones: Uso directo y simple para acciones del cliente. | Riesgo de Doble Fetching: Si se usa en componentes sin useAsyncData, el cliente ejecuta la solicitud de nuevo durante la hidratación. |
| useAsyncData | Control total: Permite envolver cualquier lógica asíncrona (no solo HTTP). SSR-Friendly: Garantiza que los datos se obtengan una sola vez (en el servidor) y se transfieran al cliente (prevención de double-fetching). Estado reactivo completo: Retorna data, pending, error, y status (un string que indica el estado: "idle", "pending", "success", "error"). Optimización Nuxt 4: El valor data por defecto es shallowRef (reactividad superficial), lo que reduce la sobrecarga de la CPU con estructuras de datos grandes, mejorando el rendimiento. | Mayor verbosidad: Requiere definir explícitamente el manejador (handler) y una clave única (key). |
| useFetch | Sintaxis simple (DX): La forma más sencilla de realizar una solicitud SSR-segura. Mecanismo automático de keying: Genera una clave automáticamente basada en la URL y las opciones. Tipado fuerte: Es capaz de inferir los tipos de respuesta y URL de las rutas de servidor. | Limitado: Solo es adecuado para llamadas a un único endpoint HTTP. No se recomienda para mutaciones (POST, PUT, DELETE) en la fase de setup. |
Cuándo usarlo desde el lado del cliente y cuándo desde el lado del server
La diferenciación clave aquí es la necesidad de Transferencia de Estado (State Transfer) para la renderización universal (SSR), que previene la "doble obtención de datos" (double-fetching).
Lado del Servidor (SSR) y Universal Rendering
Cuando se utiliza Nuxt para la renderización del lado del servidor y se necesita que los datos estén presentes en el HTML inicial (esencial para SEO y rendimiento), debe utilizar las funciones que manejan la transferencia de estado a través del payload:
| Contexto | Herramienta Recomendada | Razón |
|---|---|---|
| Recuperación inicial de datos (GET) en páginas/componentes. | useFetch o useAsyncData | Aseguran que la data se obtenga solo una vez en el servidor y se hidrate en el cliente, evitando la doble solicitud. |
| Llamadas a Rutas de API internas (desde el código del servidor). | $fetch | Nuxt intercepta la llamada y la ejecuta directamente, sin realizar una solicitud HTTP real, lo cual es más rápido. |
Advertencia: Si usa $fetch solo para la obtención inicial de datos en un componente renderizado por el servidor (<script setup>), el cliente ejecutará la llamada de red nuevamente durante la hidratación, resultando en doble fetching.
Lado del Cliente (Client-Side)
Las operaciones que se ejecutan después de la hidratación o en respuesta a la interacción del usuario deben usar el utility de bajo nivel:
| Contexto | Herramienta Recomendada | Razón |
|---|---|---|
| Mutaciones (POST, PUT, DELETE) activadas por eventos de usuario. | $fetch | Es la forma preferida para realizar llamadas HTTP directas del lado del cliente. El estado no necesita ser transferido desde el servidor, por lo que el overhead de useAsyncData es innecesario. |
| Datos no críticos para SEO o dependientes del cliente (ej. geolocalización). | useFetch/useAsyncData con server: false | Esto deshabilita la ejecución del manejador en el servidor, aplazando la obtención de datos hasta que se complete la hidratación del lado del cliente. |
| Refrescar datos existentes o ejecutar una función aplazada. | refresh()/execute() (devuelto por useFetch/useAsyncData) | Permiten volver a ejecutar la lógica de obtención de datos manualmente sin necesidad de usar $fetch. |
Piensa en
useFetchyuseAsyncDatacomo una caja fuerte que viaja del servidor al cliente: el servidor la llena con datos y la sella. Cuando llega al cliente, este no tiene que salir a buscar los datos (evitando la doble solicitud), solo tiene que abrir la caja fuerte.$fetch, en cambio, es una simple llamada telefónica para solicitar datos; si el servidor hace la llamada, el cliente tiene que hacerla de nuevo para obtener su propia copia.
Ejemplos Prácticos Avanzados
Patrón de Carga Optimizada con useAsyncData
// pages/dashboard.vue
<script setup lang="ts">
// Carga optimizada con manejo de estado detallado
const {
data: dashboard,
pending,
error,
refresh
} = await useAsyncData('dashboard', async () => {
// Llamadas paralelas para mejor rendimiento
const [user, stats, notifications] = await Promise.all([
$fetch('/api/user/profile'),
$fetch('/api/analytics/stats'),
$fetch('/api/notifications/unread')
])
return {
user,
stats,
notifications
}
}, {
// Opciones avanzadas
server: true, // Ejecutar en servidor
lazy: false, // Esperar antes de renderizar
default: () => ({ // Valor por defecto mientras carga
user: null,
stats: { views: 0, users: 0 },
notifications: []
})
})
// Refresco manual con debouncing
const debouncedRefresh = useDebounceFn(refresh, 300)
// Watch para refrescar automáticamente
watch(() => route.path, debouncedRefresh)
</script>Mutaciones con $fetch y Manejo de Errores
// components/ContactForm.vue
<script setup lang="ts">
const formData = ref({
name: '',
email: '',
message: ''
})
const isSubmitting = ref(false)
const submitError = ref<string | null>(null)
async function submitForm() {
isSubmitting.value = true
submitError.value = null
try {
const response = await $fetch('/api/contact', {
method: 'POST',
body: formData.value,
// Opciones adicionales
timeout: 10000,
retry: 2,
retryDelay: 1000
})
// Manejo exitoso
await navigateTo('/thank-you')
} catch (error) {
if (error.data?.message) {
submitError.value = error.data.message
} else {
submitError.value = 'Error al enviar el formulario. Inténtalo nuevamente.'
}
} finally {
isSubmitting.value = false
}
}
</script>useFetch con Transformación de Datos
// pages/products/[id].vue
<script setup lang="ts">
interface Product {
id: number
name: string
price: number
description: string
category: string
}
interface ProductWithDetails extends Product {
formattedPrice: string
isInStock: boolean
relatedProducts: Product[]
}
const { data: product, pending } = await useFetch<ProductWithDetails>(
`/api/products/${route.params.id}`,
{
// Transformación de datos
transform: (data: Product) => ({
...data,
formattedPrice: new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN'
}).format(data.price),
isInStock: data.price > 0,
relatedProducts: [] // Se llenará después
}),
// Caching avanzado
server: true,
key: `product-${route.params.id}`,
// Opciones de caché
getCachedData: (key) => {
// Lógica de caché personalizada
return nuxtApp.staticData[key] || nuxtApp.payload.data[key]
}
}
)
// Cargar productos relacionados después
watchEffect(async () => {
if (product.value?.category) {
const { data: related } = await $fetch<Product[]>(`/api/products/related/${product.value.category}`)
product.value.relatedProducts = related
}
})
</script>Mejores Prácticas y Patrones
1. Estrategia de Claves Únicas
// ✅ Buenas prácticas de keying
const { data } = await useAsyncData(`user-${userId}`, () => $fetch(`/api/users/${userId}`));
// Para datos dependientes de múltiples parámetros
const { data } = await useAsyncData(`search-${searchTerm}-${page}-${category}`, () =>
$fetch("/api/search", {
query: { q: searchTerm, page, category }
})
);2. Manejo de Estados de Carga
// components/DataLoader.vue
<script setup lang="ts">
interface AsyncDataState<T> {
data: Ref<T | null>
pending: Ref<boolean>
error: Ref<Error | null>
refresh: () => Promise<void>
}
// Composable reusable
function useAsyncDataWithState<T>(
key: string,
handler: () => Promise<T>
): AsyncDataState<T> {
const { data, pending, error, refresh } = useAsyncData(key, handler)
return {
data,
pending,
error,
refresh
}
}
// Uso en componente
const { data: posts, pending, error } = useAsyncDataWithState(
'posts',
() => $fetch('/api/posts')
)
</script>
<template>
<div>
<div v-if="pending" class="loading">
Cargando posts...
</div>
<div v-else-if="error" class="error">
Error: {{ error.message }}
<button @click="refresh">Reintentar</button>
</div>
<div v-else-if="data" class="posts">
<PostCard
v-for="post in data"
:key="post.id"
:post="post"
/>
</div>
</div>
</template>3. Optimización de Red con useLazyFetch
// Para datos no críticos que pueden cargar después
const { data: comments } = await useLazyFetch(`/api/posts/${postId}/comments`, {
server: false, // Solo en cliente
lazy: true // No bloquear renderizado
});
// Para datos que se actualizan frecuentemente
const { data: liveScore } = await useFetch("/api/live-score", {
server: false,
refresh: {
// Refrescar cada 30 segundos
interval: 30000
}
});Consideraciones de Rendimiento
1. Shallow Refs en Nuxt 4
Nuxt 4 optimiza el rendimiento usando shallowRef por defecto para los datos:
// En Nuxt 4, esto es shallowRef por defecto
const { data } = await useAsyncData("large-dataset", () => $fetch("/api/large-dataset"));
// Si necesitas reactividad profunda (úselo con precaución)
const { data } = await useAsyncData("deep-reactive", () => $fetch("/api/nested-data"), {
deep: true // Activa reactividad profunda
});2. Estrategias de Cache
// Cache a nivel de componente
const { data } = await useFetch("/api/config", {
key: "app-config",
// Cache por 5 minutos
server: true,
transform: data => {
// Los datos permanecerán en caché
return data;
}
});
// Cache condicional
const { data } = await useFetch("/api/user-preferences", {
key: `user-prefs-${userId}`,
server: false,
// Solo cachear si no hay errores
getCachedData: key => {
const cached = nuxtApp.staticData[key] || nuxtApp.payload.data[key];
return cached && !cached.error ? cached : null;
}
});Integración con TypeScript
Tipado Fuerte para useFetch
// types/api.ts
export interface User {
id: number
name: string
email: string
avatar?: string
}
export interface ApiResponse<T> {
data: T
message: string
success: boolean
}
// pages/users/[id].vue
<script setup lang="ts">
const route = useRoute()
const { data: user } = await useFetch<ApiResponse<User>>(`/api/users/${route.params.id}`)
// Acceso tipado seguro
if (user.value?.data) {
console.log(user.value.data.name) // TypeScript conoce el tipo
}
</script>Tipado para useAsyncData
// composables/useDashboard.ts
export interface DashboardData {
user: User;
stats: {
views: number;
clicks: number;
conversions: number;
};
recentActivity: Activity[];
}
export function useDashboardData() {
return useAsyncData<DashboardData>("dashboard", async () => {
const [userResponse, statsResponse, activityResponse] = await Promise.all([$fetch<ApiResponse<User>>("/api/user/profile"), $fetch<ApiResponse<Stats>>("/api/analytics/stats"), $fetch<ApiResponse<Activity[]>>("/api/activity/recent")]);
return {
user: userResponse.data,
stats: statsResponse.data,
recentActivity: activityResponse.data
};
});
}Depuración y Monitorización
Herramientas de Depuración
// Habilitar modo debug en desarrollo
const { data } = await useFetch("/api/debug-example", {
// Muestra información de depuración en consola
onRequestError({ request, error }) {
console.error("Request error:", { request, error });
},
onResponseError({ response }) {
console.error("Response error:", response.status, response.statusText);
},
onResponse({ response }) {
console.log("Response received:", response._data);
}
});Monitorización de Performance
// Composable para medir performance
function useTrackedFetch<T>(url: string, options = {}) {
const startTime = Date.now();
return useFetch<T>(url, {
...options,
onResponse() {
const duration = Date.now() - startTime;
console.log(`Fetch to ${url} took ${duration}ms`);
// Enviar a analytics si es lento
if (duration > 1000) {
$fetch("/api/analytics/slow-request", {
method: "POST",
body: { url, duration }
});
}
}
});
}Conclusión
La elección correcta entre $fetch, useFetch y useAsyncData es fundamental para construir aplicaciones Nuxt 4 eficientes y mantenibles:
$fetchpara mutaciones y llamadas directas del lado del clienteuseFetchpara la obtención simple de datos con SSRuseAsyncDatapara lógica asíncrona compleja y control total del estado
Comprender estas diferencias no solo previene problemas comunes como el doble fetching, sino que también permite aprovechar al máximo las capacidades de renderizado universal de Nuxt 4, construyendo aplicaciones más rápidas, eficientes y con mejor experiencia de usuario.
Esta guía se basa en la documentación oficial de Nuxt 4 y las mejores prácticas establecidas por la comunidad. Para profundizar, consulta la documentación oficial de Nuxt y los ejemplos en el repositorio de Nuxt.