データ取得:useFetch
AliceがMegaShopのホームページを開くと, 空白のページが表示されました。商品データはクライアントがリクエストするまで取得されず, レンダリングに3秒かかりました。CharlieはNuxt 3のuseFetchがサーバー側でデータを事前取得できることを知っていました。HTMLに直接商品情報を含めることで, ファーストビューで即座にコンテンツが表示されます。
1. 学ぶ内容
- useFetch / useAsyncData:SSR事前取得とクライアントハイドレーションの完全プロセス
- 生の$fetchリクエストとラップされたuseFetchリクエストの違い
- リクエストオプション:server/lazy/immediate/dedupe/transform/pick
- リフレッシュとポーリング:refresh() / watchモード
- MegaShop商品一覧:SSR事前取得の実践
2. 消費者の実話
(1) ペインポイント:3秒間の空白画面
AliceがスマホでMegaShopを開くと, 3秒間スピナーが回る空白画面が表示され, その後ようやく商品が現れました。地下鉄の電波が弱い環境ではさらに悪く, 5秒間空白でした。Charlieが分析したところ, 商品データはクライアント側のJavaScriptが読み込まれるまで取得されないことがわかりました。レンダリングプロセスは「JSダウンロード → 実行 → フェッチ → レンダリング」という順序で, 非常に遅いのです。
(2) useFetch SSR事前取得による解決策
useFetchはSSRフェーズでデータを取得し, HTMLに直接商品コンテンツを含めます:
VUE
<script setup lang="ts">
// サーバー:SSR中にデータを取得
// クライアント:事前取得済みデータを使用, 重複リクエストなし
const { data: products } = await useFetch('/api/products')
</script>
(3) 結果:ファーストビュー読み込み時間が3秒から0.8秒に短縮
SSR事前取得後, Aliceはページを開くとすぐに商品が見えます。LCPは3秒から0.8秒に低下し, ユーザー体験スコアは52から94に改善しました。
3. useFetchとuseAsyncData
(1) SSRデータ事前取得からクライアントハイドレーションまでの流れ
sequenceDiagram
participant B as ブラウザ
participant S as Nuxtサーバー
participant A as APIサーバー
B->>S: GET /products
S->>A: fetch /api/products
A-->>S: JSONデータ
S->>S: データ付きHTMLをレンダリング
S-->>B: HTML + payload (データ埋め込み)
B->>B: HTMLを表示 (即時ファーストペイント)
B->>B: payloadでハイドレート (再フェッチなし)
(2) useFetchとuseAsyncDataの比較
| 項目 | useFetch | useAsyncData |
|---|---|---|
| 目的 | $fetchのラッパー | 汎用データ取得 |
| リクエスト方法 | 内蔵$fetch | カスタムハンドラ |
| URL反応性 | ✅ URL変更時に自動再リクエスト | ❌ 手動watchが必要 |
| 型推論 | ✅ レスポンス型を自動推論 | ⚠️ ジェネリクス型を手動指定 |
| ユースケース | ほとんどのAPIリクエスト | 非標準リクエスト/複雑ロジック |
(1) ▶ サンプル:useFetchの基本使用法
VUE
<!-- pages/products/index.vue -->
<template>
<div>
<h1>商品</h1>
<div v-if="pending">読み込み中...</div>
<div v-else-if="error">商品の読み込みに失敗しました</div>
<div v-else>
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</div>
</div>
</template>
<script setup lang="ts">
const { data: products, pending, error } = await useFetch('/api/products')
</script>
出力:
TEXT
// 実行成功
(2) ▶ サンプル:useAsyncDataのカスタムハンドラ
VUE
<script setup lang="ts">
const { data: stats } = await useAsyncData('product-stats', async () => {
// カスタムデータ取得ロジック
const [total, featured, onSale] = await Promise.all([
$fetch('/api/products/count'),
$fetch('/api/products/featured'),
$fetch('/api/products/on-sale')
])
return { total, featured, onSale }
})
</script>
出力:
TEXT
// 実行成功
4. $fetchとuseFetch
(1) 主要な違いの比較
| 項目 | $fetch | useFetch |
|---|---|---|
| SSRデータ引き渡し | ❌ 引き渡しなし | ✅ 自動payload引き渡し |
| リアクティブ | ❌ 生データ | ✅ refリアクティブ |
| ステータス管理 | ❌ なし | ✅ pending/error/refresh |
| 重複リクエスト | ❌ 重複の可能性 | ✅ 重複排除 |
| ユースケース | イベントハンドリング/APIルート内 | ページデータ取得 |
(1) ▶ サンプル:$fetchをイベントハンドリングに使用
VUE
<script setup lang="ts">
// $fetchは一回きりのリクエスト用 (SSR payload不要)
async function submitOrder() {
const order = await $fetch('/api/orders', {
method: 'POST',
body: { items: cart.value, total: totalPrice.value }
})
navigateTo(`/orders/${order.id}`)
}
</script>
出力:
TEXT
// 実行成功
(2) ▶ サンプル:サーバーAPI内で$fetchを使用
TYPESCRIPT
// server/api/products/index.get.ts
export default defineEventHandler(async (event) => {
// サーバーハンドラ内では$fetchが正しい選択
const query = getQuery(event)
const products = await $fetch('https://api.supplier.com/products', {
params: { category: query.category }
})
return products
})
出力:
TEXT
// 実行成功
5. リクエストオプションの詳解
(1) 主要オプションクイックリファレンス
| オプション | 型 | デフォルト | 説明 |
|---|---|---|---|
| server | boolean | true | サーバー側で実行するかどうか |
| lazy | boolean | false | 遅延読み込みを有効にするか (非ブロッキングナビゲーション) |
| immediate | boolean | true | 即時実行するかどうか |
| dedupe | string | 'cancel' | 重複リクエストポリシー |
| transform | function | - | レスポンスデータを変換 |
| pick | array | - | 指定フィールドのみ抽出 |
| default | function | - | データ読み込み前のデフォルト値 |
(1) ▶ サンプル:Lazyモード (非ブロッキングナビゲーション)
VUE
<template>
<div>
<h1>おすすめ商品</h1>
<!-- lazy:バックグラウンドでデータを読み込み, ページは即時レンダリング -->
<div v-if="pending">おすすめを読み込み中...</div>
<div v-else>
<ProductCard v-for="p in recommendations" :key="p.id" :product="p" />
</div>
</div>
</template>
<script setup lang="ts">
const { data: recommendations, pending } = await useFetch('/api/recommendations', {
lazy: true
})
</script>
出力:
TEXT
// 実行成功
(2) ▶ サンプル:transformとpick
VUE
<script setup lang="ts">
// Transform:保存前にデータを処理
const { data: products } = await useFetch('/api/products', {
transform: (data: any[]) => {
return data.map(p => ({
...p,
formattedPrice: new Intl.NumberFormat('en-US', {
style: 'currency', currency: 'USD'
}).format(p.price)
}))
}
})
// Pick:特定フィールドのみ抽出 (payloadサイズを削減)
const { data: productNames } = await useFetch('/api/products', {
pick: ['id', 'name', 'price']
})
</script>
出力:
TEXT
// 実行成功
(3) ▶ サンプル:server: false (クライアント専用取得)
VUE
<script setup lang="ts">
// SSRフェッチをスキップ - クライアント側のみフェッチ
const { data: userWishlist } = await useFetch('/api/wishlist', {
server: false,
default: () => [] // データ読み込み前のデフォルト値
})
</script>
出力:
TEXT
// 実行成功
6. リフレッシュとポーリング
(1) リフレッシュ戦略の比較
| 戦略 | アプローチ | シナリオ | 頻度 |
|---|---|---|---|
| 手動リフレッシュ | refresh() | ユーザーアクション後 | オンデマンド |
| Watch自動リフレッシュ | watchオプション | パラメータ変更時 | パラメータ変更時 |
| ポーリング | setInterval + refresh | リアルタイムデータ | 定期実行 |
| リアルタイム | WebSocket | 超リアルタイム | プッシュ |
(1) ▶ サンプル:手動リフレッシュ
VUE
<template>
<div>
<h1>商品</h1>
<button @click="refresh()" :disabled="pending">
{{ pending ? 'リフレッシュ中...' : 'リフレッシュ' }}
</button>
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</div>
</template>
<script setup lang="ts">
const { data: products, pending, refresh } = await useFetch('/api/products')
</script>
出力:
TEXT
// 実行成功
(2) ▶ サンプル:パラメータ値変更時の自動リフレッシュ
VUE
<template>
<div>
<select v-model="selectedCategory">
<option value="all">すべて</option>
<option value="electronics">エレクトロニクス</option>
<option value="clothing">衣類</option>
</select>
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</div>
</template>
<script setup lang="ts">
const selectedCategory = ref('all')
// selectedCategoryが変更されると自動再フェッチ
const { data: products } = await useFetch('/api/products', {
query: { category: selectedCategory },
watch: [selectedCategory]
})
</script>
出力:
TEXT
// 実行成功
(3) ▶ サンプル:リアルタイム在庫のポーリング
VUE
<script setup lang="ts">
const { data: stock, refresh } = await useFetch('/api/stock/live', {
server: false // クライアント専用ポーリング
})
// 30秒ごとにポーリング
const pollInterval = setInterval(() => {
refresh()
}, 30000)
onUnmounted(() => clearInterval(pollInterval))
</script>
出力:
TEXT
// 実行成功
7. 総合例:MegaShop商品一覧SSR事前取得
VUE
<!-- pages/products/index.vue -->
<template>
<div class="product-list-page">
<h1>MegaShop商品</h1>
<!-- フィルター -->
<div class="filters">
<select v-model="filters.category" @change="applyFilters">
<option value="">全カテゴリ</option>
<option value="electronics">エレクトロニクス</option>
<option value="clothing">衣類</option>
<option value="home">ホーム&ガーデン</option>
</select>
<select v-model="filters.sort">
<option value="popular">人気順</option>
<option value="price-asc">価格:安い順</option>
<option value="price-desc">価格:高い順</option>
</select>
</div>
<!-- 読み込み中状態 -->
<div v-if="pending" class="loading">商品を読み込み中...</div>
<!-- エラー状態 -->
<div v-else-if="error" class="error">
商品の読み込みに失敗しました。<button @click="refresh()">再試行</button>
</div>
<!-- 商品グリッド -->
<div v-else class="product-grid">
<ProductCard
v-for="p in products"
:key="p.id"
:product="p"
@add-to-cart="addToCart"
/>
</div>
<!-- ページネーション -->
<div class="pagination">
<button :disabled="page <= 1" @click="page--">前へ</button>
<span>{{ page }}ページ</span>
<button @click="page++">次へ</button>
</div>
</div>
</template>
<script setup lang="ts">
interface Product {
id: number; name: string; price: number; image: string
}
const page = ref(1)
const filters = reactive({
category: '',
sort: 'popular'
})
// リアクティブクエリ付きSSR事前取得
const { data: products, pending, error, refresh } = await useFetch<Product[]>('/api/products', {
query: computed(() => ({
page: page.value,
category: filters.category || undefined,
sort: filters.sort
})),
default: () => [],
transform: (data: Product[]) => data.map(p => ({
...p,
formattedPrice: `$${p.price.toLocaleString()} USD`
}))
})
const cart = useState<Product[]>('cart', () => [])
function addToCart(product: Product) {
cart.value.push(product)
}
function applyFilters() {
page.value = 1
refresh()
}
// ページ変更を監視して自動リフレッシュ
watch(page, () => refresh())
</script>
❓ よくある質問
Q
useFetchはクライアントで重複リクエストを引き起こしますか?A いいえ。Nuxt 3のpayloadメカニズムはSSR中に取得したデータをHTMLにシリアライズし, クライアントはハイドレーション時にそれを直接使用するため, 重複リクエストは発生しません。
Q いつuseFetchの代わりに$fetchを使うべきですか?
A イベントハンドラ (ボタンクリックでフォームを送信する時など)やサーバーAPI内から呼び出す場合は$fetchを使います。ページの初期データ取得にはuseFetchを使います。
Q
useFetchのキーはどのように生成されますか?A デフォルトではURLとリクエストオプションから一意のキーが自動生成されます。手動で制御する必要がある場合 (複数リクエスト間でキャッシュを共有するなど)は,
keyパラメータを渡すことができます。Q
lazyとserver: falseの違いは何ですか?A
lazyはSSR中もリクエストを実行しますが, ナビゲーションをブロックしません (ページが先にレンダリングされ, データを待ちます)。server: falseはSSRリクエストを完全にスキップし, クライアント側でのみデータを取得します。Q なぜ
useFetchのレスポンスデータはrefであり, 生の値ではないのですか?A データは変更される可能性があり (refresh/watch),
refがリアクティブ性を保証します。分割代入時はtoRefsを使うか, .valueで直接アクセスしてください。Q
dedupeの"cancel"と"defer"オプションの違いは何ですか?A "cancel" (デフォルト)は前の未完了リクエストをキャンセルして新しいリクエストを開始します。"defer"は前のリクエストの結果を再利用し, 新しいリクエストを開始しません。
📖 まとめ
- useFetch = SSR事前取得 + クライアントハイドレーション + リアクティブ状態。ページデータ取得の推奨方法
- $fetchは生のリクエストツールで, イベントハンドリングやサーバーAPI内部の呼び出しに適しています
- 「lazy」で非ブロッキングナビゲーション, 「server: false」でSSRをスキップ, 「transform/pick」でデータを最適化
- refresh()で手動リフレッシュ, watchオプションでパラメータ変更を自動追跡
- MegaShopは
useFetchとwatchで商品一覧のSSR事前取得とフィルター同期を実装
📝 練習問題
- 基本問題 (難易度:⭐):
useFetchを使って公開API (jsonplaceholderなど)からデータを取得し, データリストを表示し, ソースコードにデータが含まれているか確認してSSRを検証してください。 - 応用問題 (難易度:⭐⭐):ページネーション付き商品一覧を実装し, 「次のページ」ボタンクリック時に
watchで自動的にデータを再読み込みするようにしてください。 - チャレンジ (難易度:⭐⭐⭐):主要商品のSSR事前取得とおすすめ商品のlazy読み込みを同時に実装し, 2つのアプローチのユーザー体験の違いを比較してください。
---|



