Zademy

Nuxt 4:$fetch、useFetch、useAsyncData - データ取得完全ガイド

Nuxt; Vue; Fetching; SSR; DataFetching
words 単語

Nuxt のデータ取得には $fetch、useAsyncData、useFetch の 3 つのレイヤーがあります。それぞれアーキテクチャ上の役割が違うので、混同すると二重リクエストや無駄なオーバーヘッドに悩まされます。ここでは私なりの使い分けを整理しておきます。

$fetch は低レベルな HTTP クライアントです。純粋なネットワークリクエストを実行するだけで、SSR の状態転送は関知しません。SSR コンポーネント内で直接使うと、クライアント側で同じリクエストがもう一度飛んでしまいます。

useAsyncData は状態プリミティブです。非同期状態のライフサイクルを管理し、サーバーとクライアント間のデータ転送を保証します。結果を Nuxt の payload にシリアライズして、二重リクエストを防ぎます。

useFetch は useAsyncData と $fetch を組み合わせた便利なラッパーです。要するに構文糖衣で、よくある「単一のエンドポイントから GET で取ってくる」ケースをシンプルに書けます。

いつどれを使用するのが最適か

選択は、その操作がどのコンテキストで実行されるかに依存します。

1. useFetch:シンプルな初期データ取得(GET)

単一のエンドポイントからの GET リクエストで、SSR サポートが必要な場合は useFetch を使います。一番シンプルに書けます。

例えば、記事リストを取得するページならこれで十分です。

// app/pages/posts.vue
<script setup lang="ts">
  // リクエストを一度(サーバー上で)実行し、状態を転送します。
  const { data: posts } = await useFetch('/api/posts')
</script>

2. useAsyncData:複雑な非同期ロジックまたは非HTTPデータソース

非同期処理が単純な HTTP 呼び出しでない場合、useAsyncData が主役になります。任意の非同期 Promise をラップできるのが強みです。

複数の $fetch を並列で投げて結果をまとめる場合:

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

GraphQL クライアントやデータベース ORM など、$fetch を使わないクライアントを統合する場合:

// myGetFunctionはデータクライアントのカスタム関数です
const { data } = await useAsyncData("users", () => myGetFunction("users"));

3. $fetch:ミューテーションとクライアント/サーバー駆動のアクション

ユーザーイベント(クリック、フォーム送信など)で駆動されるクライアント側の操作には $fetch を使います。SSR の状態同期が不要な低レベル呼び出しにも適しています。

フォーム送信のようなミューテーション(POST/PUT/DELETE)は useFetch ではなく $fetch で行うべきです。ミューテーションに useFetch を使っても状態ハイドレーションの恩恵を受けられず、無駄が生じます。

// イベントハンドラー内で使用
async function submitForm() {
	await $fetch("/api/contact", {
		method: "POST",
		body: formData.value
	});
}

Nuxt の内部サーバールート(server/api 以下)の呼び出しでも $fetch が有利です。HTTP ラウンドトリップを回避してハンドラー関数を直接呼び出すので、サーバー上で速くなります。

利点と欠点

$fetch の強みは内部呼び出しの最適化とミューテーションでのシンプルさです。ただし useAsyncData なしでコンポーネント内に書くと、ハイドレーション時にクライアント側で再度リクエストが飛んでしまいます。

useAsyncData は任意の非同期ロジックをラップでき、SSR フレンドリーで、data、pending、error、status("idle"、"pending"、"success"、"error")を返します。Nuxt 4 ではデフォルトで data が shallowRef になり、大きなデータ構造でも CPU オーバーヘッドを抑えられます。代償は、handler と一意の key を明示的に書く必要があることです。

useFetch は SSR セーフなリクエストを一番シンプルに書けて、URL とオプションから自動的にキーを生成し、サーバールートの型も推論してくれます。ただし単一のエンドポイント呼び出しにしか適しておらず、setup 内でのミューテーションには向きません。

クライアント側とサーバー側のどちらから使用するか

ここで肝になるのは、ユニバーサルレンダリング(SSR)における状態転送(State Transfer)です。要するに、サーバーで取ったデータをクライアントに渡して、二重取得を防ぐ仕組みのことです。

サーバー側(SSR)とユニバーサルレンダリング

サーバー側レンダリングで初期 HTML にデータを含める必要がある場合(SEO やパフォーマンスのため)、payload を通じた状態転送を扱う関数を使わなければなりません。

ページやコンポーネントでの初期データ取得(GET)には useFetch か useAsyncData を使います。これにより、データがサーバーで一度だけ取得され、クライアントでハイドレートされます。

一方、サーバーコードから内部 API ルートを呼ぶ場合は $fetch が速いです。Nuxt が呼び出しをインターセプトして、実際の HTTP リクエストを飛ばさずに直接実行するからです。

サーバーレンダリングされたコンポーネントの setup で $fetch だけを使うと、クライアント側でハイドレーション時に再度ネットワーク呼び出しが走ります。これが二重フェッチの正体です。

クライアント側(Client-Side)

ハイドレーション後やユーザー操作に応答する処理では $fetch が基本です。

ユーザーイベントでトリガーされるミューテーション(POST、PUT、DELETE)には $fetch を使います。状態をサーバーから転送する必要がないので、useAsyncData のオーバーヘッドは不要です。

SEO に重要でないデータやクライアント依存のデータ(ジオロケーションなど)を取得する場合は、useFetch / useAsyncData に server: false を指定します。サーバー側での実行が無効になり、クライアント側のハイドレーション完了後にデータ取得が始まります。

既存データの更新には useFetch / useAsyncData が返す refresh() や execute() を使います。$fetch を直接呼ぶよりも、元の取得ロジックを再実行できるので安全です。

useFetch と useAsyncData を、サーバーからクライアントに渡す金庫だと考えてください。サーバーがデータで満たして封印し、クライアントに届いたら封を開けるだけで済みます。もう一度取りに行く必要はありません。一方 $fetch は単なる電話です。サーバーがかけても、クライアントは自分のコピーを得るためにもう一度かけ直さなければなりません。

高度な実用例

useAsyncDataを使用した最適化されたロードパターン

// pages/dashboard.vue
<script setup lang="ts">
// 詳細な状態管理を備えた最適化されたロード
const {
  data: dashboard,
  pending,
  error,
  refresh
} = await useAsyncData('dashboard', async () => {
  // より良いパフォーマンスのための並列呼び出し
  const [user, stats, notifications] = await Promise.all([
    $fetch('/api/user/profile'),
    $fetch('/api/analytics/stats'),
    $fetch('/api/notifications/unread')
  ])

  return {
    user,
    stats,
    notifications
  }
}, {
  // 高度なオプション
  server: true,        // サーバー上で実行
  lazy: false,         // レンダリング前に待機
  default: () => ({    // ロード中のデフォルト値
    user: null,
    stats: { views: 0, users: 0 },
    notifications: []
  })
})

// デバウンスを使用した手動更新
const debouncedRefresh = useDebounceFn(refresh, 300)

// 自動更新のためのWatch
watch(() => route.path, debouncedRefresh)
</script>

$fetchを使用したミューテーションとエラー処理

// 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,
      // 追加オプション
      timeout: 10000,
      retry: 2,
      retryDelay: 1000
    })

    // 成功処理
    await navigateTo('/thank-you')
  } catch (error) {
    if (error.data?.message) {
      submitError.value = error.data.message
    } else {
      submitError.value = 'フォームの送信中にエラーが発生しました。もう一度お試しください。'
    }
  } finally {
    isSubmitting.value = false
  }
}
</script>

useFetchを使用したデータ変換

// 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}`,
  {
    // データ変換
    transform: (data: Product) => ({
      ...data,
      formattedPrice: new Intl.NumberFormat('ja-JP', {
        style: 'currency',
        currency: 'JPY'
      }).format(data.price),
      isInStock: data.price > 0,
      relatedProducts: [] // 後で埋められます
    }),
    // 高度なキャッシング
    server: true,
    key: `product-${route.params.id}`,
    // キャッシュオプション
    getCachedData: (key) => {
      // カスタムキャッシュロジック
      return nuxtApp.staticData[key] || nuxtApp.payload.data[key]
    }
  }
)

// 関連製品を後でロード
watchEffect(async () => {
  if (product.value?.category) {
    const { data: related } = await $fetch<Product[]>(`/api/products/related/${product.value.category}`)
    product.value.relatedProducts = related
  }
})
</script>

ベストプラクティスとパターン

1. 一意のキー戦略

useAsyncData のキーはユニークで意味のあるものにします。複数パラメータに依存するデータは、すべてパラメータを含めないとキャッシュが衝突します。

// ✅ キーイングのベストプラクティス
const { data } = await useAsyncData(`user-${userId}`, () => $fetch(`/api/users/${userId}`));

// 複数のパラメータに依存するデータの場合
const { data } = await useAsyncData(`search-${searchTerm}-${page}-${category}`, () =>
	$fetch("/api/search", {
		query: { q: searchTerm, page, category }
	})
);

2. ロード状態の処理

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

// 再利用可能なコンポーザブル
function useAsyncDataWithState<T>(
  key: string,
  handler: () => Promise<T>
): AsyncDataState<T> {
  const { data, pending, error, refresh } = useAsyncData(key, handler)

  return {
    data,
    pending,
    error,
    refresh
  }
}

// コンポーネントでの使用
const { data: posts, pending, error } = useAsyncDataWithState(
  'posts',
  () => $fetch('/api/posts')
)
</script>

<template>
  <div>
    <div v-if="pending" class="loading">
      投稿を読み込んでいます...
    </div>

    <div v-else-if="error" class="error">
      エラー: {{ error.message }}
      <button @click="refresh">再試行</button>
    </div>

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

3. useLazyFetchを使用したネットワーク最適化

// 後でロードできる重要でないデータの場合
const { data: comments } = await useLazyFetch(`/api/posts/${postId}/comments`, {
	server: false, // クライアントのみ
	lazy: true // レンダリングをブロックしない
});

// 頻繁に更新されるデータの場合
const { data: liveScore } = await useFetch("/api/live-score", {
	server: false,
	refresh: {
		// 30秒ごとに更新
		interval: 30000
	}
});

パフォーマンスに関する考慮事項

1. Nuxt 4のShallow Refs

Nuxt 4 はデフォルトで data に shallowRef を使います。大きなデータ構造でも深い再帰監視のコストを避けられるので、ほとんどのケースではこのままで十分です。

// Nuxt 4では、これはデフォルトでshallowRefです
const { data } = await useAsyncData("large-dataset", () => $fetch("/api/large-dataset"));

// 深いリアクティビティが必要な場合(慎重に使用)
const { data } = await useAsyncData("deep-reactive", () => $fetch("/api/nested-data"), {
	deep: true // 深いリアクティビティを有効にする
});

2. キャッシュ戦略

// コンポーネントレベルのキャッシュ
const { data } = await useFetch("/api/config", {
	key: "app-config",
	// 5分間キャッシュ
	server: true,
	transform: data => {
		// データはキャッシュされたままになります
		return data;
	}
});

// 条件付きキャッシュ
const { data } = await useFetch("/api/user-preferences", {
	key: `user-prefs-${userId}`,
	server: false,
	// エラーがない場合のみキャッシュ
	getCachedData: key => {
		const cached = nuxtApp.staticData[key] || nuxtApp.payload.data[key];
		return cached && !cached.error ? cached : null;
	}
});

TypeScriptとの統合

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}`)

// 型安全なアクセス
if (user.value?.data) {
  console.log(user.value.data.name) // TypeScriptは型を知っています
}
</script>

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

デバッグと監視

デバッグツール

開発中は onRequestError、onResponseError、onResponse コールバックでリクエストの詳細をログに出せます。

// 開発中のデバッグモードを有効にする
const { data } = await useFetch("/api/debug-example", {
	// コンソールにデバッグ情報を表示
	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);
	}
});

パフォーマンス監視

// パフォーマンスを測定するコンポーザブル
function useTrackedFetch<T>(url: string, options = {}) {
	const startTime = Date.now();

	return useFetch<T>(url, {
		...options,
		onResponse() {
			const duration = Date.now() - startTime;
			console.log(`${url}へのフェッチに${duration}msかかりました`);

			// 遅い場合は分析に送信
			if (duration > 1000) {
				$fetch("/api/analytics/slow-request", {
					method: "POST",
					body: { url, duration }
				});
			}
		}
	});
}

結論

3 つの違いを一言でまとめます。ミューテーションとクライアント側の直接呼び出しには $fetch。SSR を使ったシンプルなデータ取得には useFetch。複雑な非同期ロジックや状態の完全な制御には useAsyncData。

この使い分けを押さえておけば、二重フェッチングの罠を避けつつ、Nuxt 4 のユニバーサルレンダリングを最大限に活用できます。詳しくは Nuxt の公式ドキュメント を参照してください。