Zademy

Nuxt 4: $fetch, useFetch y useAsyncData - Guía Completa de Obtención de Datos

Nuxt; Vue; Fetching; SSR; DataFetching
words palabras

Nuxt 4 tiene tres herramientas para obtener datos y la gente se confunde con frecuencia. El problema es que parecen intercambiables pero no lo son. Cada una vive en una capa distinta del stack.

$fetch es el cliente HTTP de bajo nivel. Hace una petición de red y punto. No sabe nada de SSR ni de transferencia de estado. Si lo usas directamente en setup durante SSR, el cliente va a repetir la petición al hidratarse. Doble fetching.

useAsyncData es la primitiva de estado. Envuelve cualquier promesa asíncrona, captura el resultado en el servidor, lo serializa en el payload de Nuxt y lo transfiere al cliente. El cliente abre el payload en vez de repetir la petición. Es la que hace la magia del SSR.

useFetch es azúcar sintáctico encima de useAsyncData + $fetch. Hace lo mismo pero con menos código para el caso más común: un GET a un endpoint. Internamente llama a useAsyncData.

Cuándo usar cada una

1. useFetch: GET simple para datos iniciales

Si necesitas cargar datos en setup para una página renderizada en servidor, useFetch es lo más limpio. Genera la key automáticamente basándose en la URL, infiere tipos y maneja la transferencia de estado sin que tengas que pensar en ello.

// 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: cuando no es un GET simple

En cuanto la lógica se complica, useFetch se queda corto. useAsyncData te deja envolver cualquier promesa, no solo una llamada HTTP.

Por ejemplo, cuando necesitas hacer varias llamadas paralelas y combinar los resultados:

const { data } = await useAsyncData("dashboard", async () => {
	const [user, stats] = await Promise.all([$fetch("/api/user"), $fetch("/api/stats")]);
	return { user, stats };
});

O cuando usas un cliente que no es $fetch, como un cliente de GraphQL o un ORM:

// myGetFunction es una función personalizada de tu cliente de datos
const { data } = await useAsyncData("users", () => myGetFunction("users"));

3. $fetch: mutaciones y llamadas desde el cliente

$fetch brilla cuando no necesitas transferencia de estado. Mutaciones (POST, PUT, DELETE) disparadas por eventos de usuario, llamadas de utilidad que no alimentan el render inicial.

// Se usa dentro de un manejador de eventos (event handler)
async function submitForm() {
	await $fetch("/api/contact", {
		method: "POST",
		body: formData.value
	});
}

Usar useFetch para una mutación en setup no tiene sentido: no te beneficias de la hidratación del estado porque un POST no es algo que quieras repetir en el cliente. Es overhead sin beneficio.

Otra ventaja de $fetch: dentro de las rutas de servidor de Nuxt (server/api), intercepta la llamada y ejecuta la función del handler directamente, sin hacer una petición HTTP real. Ahorro de latencia puro.

Lo bueno y lo malo de cada una

$fetch es rápido y directo. Las llamadas internas a rutas de servidor no hacen round-trip HTTP. Es la herramienta correcta para mutaciones del lado del cliente. El problema: si lo pones en setup sin useAsyncData, el navegador repite la petición al hidratarse. Doble fetching garantizado.

useAsyncData te da control total. Funciona con cualquier fuente de datos, no solo HTTP. Gestiona la transferencia SSR, previene el doble fetching y te devuelve data, pending, error, refresh y status (un string: "idle", "pending", "success", "error"). En Nuxt 4, data es shallowRef por defecto, lo que reduce la carga de CPU con datasets grandes. La contrapartida: tienes que definir explícitamente el handler y una key única. Más verboso.

useFetch es lo más cómodo para un GET SSR-safe. Key automática, tipos inferidos, sintaxis mínima. Pero está limitado a un solo endpoint HTTP y no es adecuado para mutaciones en setup.

Cliente vs servidor

La distinción clave es si necesitas transferencia de estado para SSR o no.

Servidor (SSR)

Si los datos tienen que estar en el HTML inicial (SEO, First Contentful Paint), usa useFetch o useAsyncData. Ambas garantizan que el servidor obtiene los datos una vez y los transfiere al cliente en el payload.

Si estás escribiendo código que corre en el servidor (rutas internas de API) y necesitas llamar a otra ruta interna, usa $fetch. Nuxt ejecuta la función directamente sin petición HTTP.

El error clásico: poner $fetch solo en setup para la carga inicial. El servidor hace la petición, renderiza el HTML, y cuando el navegador carga la página... repite la petición. Doble fetching.

Cliente

Después de la hidratación o en respuesta a interacción del usuario, $fetch es la herramienta correcta. El estado no necesita viajar desde el servidor, así que el overhead de useAsyncData no aporta nada.

Para datos que no son críticos para SEO o que dependen del cliente (geolocalización, por ejemplo), puedes usar useFetch o useAsyncData con server: false. El handler se deshabilita en el servidor y se aplaza hasta que el cliente termina de hidratarse.

Para refrescar datos que ya tienes, usa refresh() o execute() (los métodos que devuelven useFetch y useAsyncData). No necesitas $fetch para eso.

Piensa en useFetch y useAsyncData como 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, solo abre la caja. $fetch es una llamada telefónica para pedir datos; si el servidor hace la llamada, el cliente tiene que hacerla de nuevo para conseguir su propia copia.

Ejemplos prácticos

Dashboard con carga paralela

// 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>

Formulario con 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>

Patrones que vale la pena conocer

Keys únicas

Cada llamada a useAsyncData necesita una key única. Si dos llamadas distintas comparten key, se pisan. Para datos que dependen de parámetros, incluye los parámetros en la key:

// ✅ 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 }
	})
);

Composable reutilizable para estados de carga

Si te encuentras repitiendo el patrón de desestructurar data, pending, error, refresh, vale la pena extraerlo:

// 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>

Carga diferida con useLazyFetch

Para datos que no son críticos para el render inicial, useLazyFetch no bloquea la navegación:

// 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
	});

Rendimiento

shallowRef por defecto en Nuxt 4

Nuxt 4 cambió el tipo de data de ref a shallowRef. Esto significa que la reactividad no penetra las propiedades anidadas del objeto. Para datasets grandes esto reduce drásticamente el coste de tracking reactivo. Si necesitas reactividad profunda (y sabes por qué la necesitas), la activas explícitamente:

// 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
});

Estrategias de caché

// 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;
	}
});

Tipos con TypeScript

useFetch tipado

// 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>

useAsyncData tipado en un composable

// 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
		};
	});
}

Debug y monitoring

Hooks de depuración

onRequestError, onResponseError y onResponse te dan visibilidad sin sacar el DevTools:

// 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);
	}
});

Medir performance de peticiones

// 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 }
				});
			}
		}
	});
}

Resumen

La regla es simple. $fetch para mutaciones y llamadas directas del cliente. useFetch para un GET con SSR cuando quieres la sintaxis más corta. useAsyncData cuando necesitas orquestar varias fuentes de datos, envolver lógica que no es HTTP, o controlar el estado con detalle. Entender esta jerarquía previene el doble fetching y te deja exprimir el render universal de Nuxt sin pelearte con la herramienta equivocada.


Basado en la documentación oficial de Nuxt. Para profundizar, ahí están los ejemplos del repositorio.