Zademy

Nuxt 4: $fetch, useFetch and useAsyncData - Complete Data Fetching Guide

Nuxt; Vue; Fetching; SSR; DataFetching
1899 words

Data fetching management in Nuxt is organized in a three-level hierarchy, where each tool has a distinct architectural role:

ToolArchitectural RoleMain PurposeSSR State Transfer (Hydration)
$fetchLow-level Utility (Raw HTTP Client)Execute pure HTTP network requests.No. If used directly in SSR components, it causes double fetching.
useAsyncDataState Primitive (Core Primitive)Manages the lifecycle of asynchronous state and guarantees data transfer between server and client.Yes. Captures the result, serializes it in Nuxt's payload, and avoids double requests.
useFetchConvenience Wrapper (Developer Wrapper)Simplifies the most common use case: fetching data from a single HTTP endpoint.Yes. Works internally with useAsyncData, inheriting its capabilities.

useFetch is essentially syntactic sugar (a shortcut) to implement useAsyncData together with $fetch.

When to Use Each One

The choice fundamentally depends on the architectural context of the operation:

1. useFetch: Simple Initial Data Retrieval (GET)

It's recommended to use useFetch for initial data retrieval through GET requests from a single endpoint, especially when you need Server-Side Rendering (SSR) support and want to avoid double data fetching. It's the option with the cleanest syntax.

Clear Example: Getting a list of articles for a page.

// app/pages/posts.vue
<script setup lang="ts">
  // Takes care of making the request once (on the server) and transferring the state.
  const { data: posts } = await useFetch('/api/posts')
</script>

2. useAsyncData: Complex Asynchronous Logic or Non-HTTP Data Sources

It's mandatory to use useAsyncData when the asynchronous operation is not a simple HTTP call to an endpoint. It's the main tool for wrapping any asynchronous promise.

Clear Examples:

  • Parallel calls: Orchestrate multiple simultaneous $fetch calls and combine them.

    const { data } = await useAsyncData("dashboard", async () => {
    	const [user, stats] = await Promise.all([$fetch("/api/user"), $fetch("/api/stats")]);
    	return { user, stats };
    });
  • Using third-party clients: Integrating clients that don't use $fetch, such as GraphQL clients or a database ORM.

    // myGetFunction is a custom function from your data client
    const { data } = await useAsyncData("users", () => myGetFunction("users"));

3. $fetch: Mutations and Client/Server-Driven Actions

It's explicitly recommended to use $fetch for interactions that are fundamentally client-side and are driven by user events (like clicks or form submissions). It's also the best option for low-level utility calls that don't require SSR state synchronization.

Clear Examples:

  • Mutations (POST/PUT/DELETE): Sending form data. Using useFetch for mutations is generally inefficient since it doesn't benefit from state hydration.

    // Used inside an event handler
    async function submitForm() {
    	await $fetch("/api/contact", {
    		method: "POST",
    		body: formData.value
    	});
    }
  • Internal Server Route Calls: Within Nuxt's API routes (e.g., server/api), $fetch avoids the HTTP request and calls the handler function directly, optimizing server-side speed.

Advantages and Disadvantages

ToolAdvantagesDisadvantages
$fetchInternal call optimization: Calls internal server routes directly, without HTTP round-trip (latency savings). Ideal for mutations: Direct and simple use for client-side actions.Risk of Double Fetching: If used in components without useAsyncData, the client executes the request again during hydration.
useAsyncDataTotal control: Allows wrapping any asynchronous logic (not just HTTP). SSR-Friendly: Guarantees that data is fetched only once (on the server) and transferred to the client (preventing double-fetching). Complete reactive state: Returns data, pending, error, and status (a string indicating the state: "idle", "pending", "success", "error"). Nuxt 4 Optimization: The data value is shallowRef by default (shallow reactivity), which reduces CPU overhead with large data structures, improving performance.More verbose: Requires explicitly defining the handler and a unique key.
useFetchSimple syntax (DX): The simplest way to make an SSR-safe request. Automatic keying mechanism: Automatically generates a key based on URL and options. Strong typing: Can infer response types and URL types from server routes.Limited: Only suitable for calls to a single HTTP endpoint. Not recommended for mutations (POST, PUT, DELETE) in the setup phase.

When to Use from Client-side vs Server-side

The key differentiation here is the need for State Transfer for universal rendering (SSR), which prevents "double data fetching".

Server-side (SSR) and Universal Rendering

When using Nuxt for server-side rendering and you need data to be present in the initial HTML (essential for SEO and performance), you must use the functions that handle state transfer through the payload:

ContextRecommended ToolReason
Initial data retrieval (GET) in pages/components.useFetch or useAsyncDataEnsure data is fetched only once on the server and hydrated on the client, avoiding double requests.
Internal API Route calls (from server code).$fetchNuxt intercepts the call and executes it directly, without making a real HTTP request, which is faster.

Warning: If you use $fetch only for initial data fetching in a server-rendered component (<script setup>), the client will execute the network request again during hydration, resulting in double fetching.

Client-side

Operations that execute after hydration or in response to user interaction should use the low-level utility:

ContextRecommended ToolReason
Mutations (POST, PUT, DELETE) triggered by user events.$fetchIt's the preferred way to make direct client-side HTTP calls. The state doesn't need to be transferred from the server, so the useAsyncData overhead is unnecessary.
Non-SEO critical data or client-dependent data (e.g., geolocation).useFetch/useAsyncData with server: falseThis disables handler execution on the server, postponing data fetching until client-side hydration is complete.
Refresh existing data or execute a deferred function.refresh()/execute() (returned by useFetch/useAsyncData)Allow re-executing the data fetching logic manually without needing to use $fetch.

Think of useFetch and useAsyncData as a safe that travels from server to client: the server fills it with data and seals it. When it arrives at the client, the client doesn't have to go out to fetch the data (avoiding double requests), it just has to open the safe. $fetch, on the other hand, is a simple phone call to request data; if the server makes the call, the client has to make it again to get its own copy.

Advanced Practical Examples

Optimized Loading Pattern with useAsyncData

// pages/dashboard.vue
<script setup lang="ts">
// Optimized loading with detailed state management
const {
  data: dashboard,
  pending,
  error,
  refresh
} = await useAsyncData('dashboard', async () => {
  // Parallel calls for better performance
  const [user, stats, notifications] = await Promise.all([
    $fetch('/api/user/profile'),
    $fetch('/api/analytics/stats'),
    $fetch('/api/notifications/unread')
  ])

  return {
    user,
    stats,
    notifications
  }
}, {
  // Advanced options
  server: true,        // Execute on server
  lazy: false,         // Wait before rendering
  default: () => ({    // Default value while loading
    user: null,
    stats: { views: 0, users: 0 },
    notifications: []
  })
})

// Manual refresh with debouncing
const debouncedRefresh = useDebounceFn(refresh, 300)

// Watch for automatic refresh
watch(() => route.path, debouncedRefresh)
</script>

Mutations with $fetch and Error Handling

// 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,
      // Additional options
      timeout: 10000,
      retry: 2,
      retryDelay: 1000
    })

    // Success handling
    await navigateTo('/thank-you')
  } catch (error) {
    if (error.data?.message) {
      submitError.value = error.data.message
    } else {
      submitError.value = 'Error submitting form. Please try again.'
    }
  } finally {
    isSubmitting.value = false
  }
}
</script>

useFetch with Data Transformation

// 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}`,
  {
    // Data transformation
    transform: (data: Product) => ({
      ...data,
      formattedPrice: new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: 'USD'
      }).format(data.price),
      isInStock: data.price > 0,
      relatedProducts: [] // Will be filled later
    }),
    // Advanced caching
    server: true,
    key: `product-${route.params.id}`,
    // Cache options
    getCachedData: (key) => {
      // Custom cache logic
      return nuxtApp.staticData[key] || nuxtApp.payload.data[key]
    }
  }
)

// Load related products after
watchEffect(async () => {
  if (product.value?.category) {
    const { data: related } = await $fetch<Product[]>(`/api/products/related/${product.value.category}`)
    product.value.relatedProducts = related
  }
})
</script>

Best Practices and Patterns

1. Unique Key Strategy

// ✅ Good keying practices
const { data } = await useAsyncData(`user-${userId}`, () => $fetch(`/api/users/${userId}`));

// For data dependent on multiple parameters
const { data } = await useAsyncData(`search-${searchTerm}-${page}-${category}`, () =>
	$fetch("/api/search", {
		query: { q: searchTerm, page, category }
	})
);

2. Loading State Management

// components/DataLoader.vue
<script setup lang="ts">
interface AsyncDataState<T> {
  data: Ref<T | null>
  pending: Ref<boolean>
  error: Ref<Error | null>
  refresh: () => Promise<void>
}

// Reusable composable
function useAsyncDataWithState<T>(
  key: string,
  handler: () => Promise<T>
): AsyncDataState<T> {
  const { data, pending, error, refresh } = useAsyncData(key, handler)

  return {
    data,
    pending,
    error,
    refresh
  }
}

// Usage in component
const { data: posts, pending, error } = useAsyncDataWithState(
  'posts',
  () => $fetch('/api/posts')
)
</script>

<template>
  <div>
    <div v-if="pending" class="loading">
      Loading posts...
    </div>

    <div v-else-if="error" class="error">
      Error: {{ error.message }}
      <button @click="refresh">Retry</button>
    </div>

    <div v-else-if="data" class="posts">
      <PostCard
        v-for="post in data"
        :key="post.id"
        :post="post"
      />
    </div>
  </div>
</template>

3. Network Optimization with useLazyFetch

// For non-critical data that can load later
const { data: comments } = await useLazyFetch(`/api/posts/${postId}/comments`, {
	server: false, // Client-side only
	lazy: true // Don't block rendering
});

// For frequently updated data
const { data: liveScore } = await useFetch("/api/live-score", {
	server: false,
	refresh: {
		// Refresh every 30 seconds
		interval: 30000
	}
});

Performance Considerations

1. Shallow Refs in Nuxt 4

Nuxt 4 optimizes performance by using shallowRef by default for data:

// In Nuxt 4, this is shallowRef by default
const { data } = await useAsyncData("large-dataset", () => $fetch("/api/large-dataset"));

// If you need deep reactivity (use with caution)
const { data } = await useAsyncData("deep-reactive", () => $fetch("/api/nested-data"), {
	deep: true // Enables deep reactivity
});

2. Cache Strategies

// Component-level cache
const { data } = await useFetch("/api/config", {
	key: "app-config",
	// Cache for 5 minutes
	server: true,
	transform: data => {
		// Data will remain in cache
		return data;
	}
});

// Conditional cache
const { data } = await useFetch("/api/user-preferences", {
	key: `user-prefs-${userId}`,
	server: false,
	// Only cache if there are no errors
	getCachedData: key => {
		const cached = nuxtApp.staticData[key] || nuxtApp.payload.data[key];
		return cached && !cached.error ? cached : null;
	}
});

TypeScript Integration

Strong Typing for 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}`)

// Type-safe access
if (user.value?.data) {
  console.log(user.value.data.name) // TypeScript knows the type
}
</script>

Typing for 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
		};
	});
}

Debugging and Monitoring

Debugging Tools

// Enable debug mode in development
const { data } = await useFetch("/api/debug-example", {
	// Show debug information in console
	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);
	}
});

Performance Monitoring

// Composable for measuring 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`);

			// Send to analytics if it's slow
			if (duration > 1000) {
				$fetch("/api/analytics/slow-request", {
					method: "POST",
					body: { url, duration }
				});
			}
		}
	});
}

Conclusion

The correct choice between $fetch, useFetch, and useAsyncData is fundamental for building efficient and maintainable Nuxt 4 applications:

  • $fetch for mutations and direct client-side calls
  • useFetch for simple data fetching with SSR
  • useAsyncData for complex asynchronous logic and total state control

Understanding these differences not only prevents common problems like double fetching but also allows you to make the most of Nuxt 4's universal rendering capabilities, building faster, more efficient applications with better user experience.


This guide is based on the official Nuxt 4 documentation and best practices established by the community. To dive deeper, consult the official Nuxt documentation and the examples in the Nuxt repository.