- Nuxt
Nuxt useFetchとuseAsyncDataの違い|リクエスト回数を実測
useFetch は「URLを渡すだけでデータを取得する」ための近道で、中身は useAsyncData と $fetch の組み合わせです。useAsyncData は、APIに限らず任意の非同期処理の結果をSSRからブラウザへ引き継ぐための汎用の仕組みです。 どちらもサーバーで取得した結果をHTMLと一緒にブラウザへ渡すため、同じAPIを二度呼ばずに済みます。
この記事では Nuxt 4.5.2 で server/api にモックAPIを作り、セットアップで $fetch を直接呼ぶと「SSRで1回+ブラウザで1回」の計2回リクエストされ、useFetch だと1回になる様子を、サーバーのアクセスログと Playwright のネットワークログで実際に確認しました。あわせて、7つの関数の早見表と使い分けフロー、主要オプション一覧、Nuxt 4 で変わった挙動(shallowRef、初期値 undefined、キーの共有)、エラー処理、キャッシュ、キーの衝突で出る警告まで、すべて実行結果つきでまとめています。
useFetch・useAsyncData・$fetch の違いの早見表
| 関数 | ひとことで | SSRの結果をブラウザへ引き継ぐ | 主な使いどころ |
|---|---|---|---|
$fetch |
ただのHTTPクライアント(ofetch) | しない | ボタン操作での送信(POST等)、useAsyncData の中身、サーバー側の処理 |
useFetch(url) |
useAsyncData + $fetch の短縮形 |
する | 画面表示に使うAPIデータの取得(いちばんよく使う) |
useAsyncData(key, fn) |
任意の非同期処理を包む | する | 複数APIの結果をまとめる、SDKやDBクライアントを使う |
useLazyFetch |
useFetch の lazy: true 版 |
する | 取得を待たずにページを切り替えたい |
useLazyAsyncData |
useAsyncData の lazy: true 版 |
する | 同上 |
useNuxtData(key) |
取得済みのデータを読むだけ | - | 別のコンポーネントからキャッシュを参照 |
refreshNuxtData(key) |
キーを指定して再取得 | - | 更新ボタン、登録後の一覧の取り直し |
clearNuxtData(key) |
キーを指定してデータを消す | - | ログアウト時などにキャッシュを消す |
実測でわかったリクエスト回数(Nuxt 4.5.2、ページを直接開いた場合)
| 書き方 | SSR | ブラウザ | 合計 |
|---|---|---|---|
セットアップで await $fetch('/api/items') |
1回 | 1回 | 2回 |
await useFetch('/api/items') |
1回 | 0回 | 1回 |
await useAsyncData('items', () => $fetch('/api/items')) |
1回 | 0回 | 1回 |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Nuxt useFetchとuseAsyncDataの違い|リクエスト回数を実測」
実測:$fetch を直接使うとリクエストが2回になる
セットアップ(<script setup>)はSSRとブラウザの両方で実行されます。$fetch を直接呼ぶと、その両方でAPIが呼ばれます。 useFetch と useAsyncData は、SSRで取得した結果をページのデータ(payload)に入れてブラウザへ渡すので、ブラウザでは呼びません。
検証に使ったコード
呼び出し回数と、呼び出し元(SSR中の内部呼び出しか、ブラウザからのHTTPか)をログに出すモックAPIです。
// server/api/items.get.ts
let count = 0
export default defineEventHandler(async (event) => {
count++
// SSR中の $fetch はHTTPを通らずに直接呼ばれるため、接続元アドレスがない
const from = event.node.req.socket?.remoteAddress ? 'ブラウザ(HTTP)' : 'SSR(内部呼び出し)'
const query = getQuery(event)
console.log(`[api] #${count} GET /api/items page=${query.page ?? 1} 呼び出し元=${from}`)
await new Promise((r) => setTimeout(r, 100))
const page = Number(query.page ?? 1)
return [
{ id: page * 10 + 1, name: `備品A(p${page})`, stock: 3 },
{ id: page * 10 + 2, name: `備品B(p${page})`, stock: 0 },
]
})
<!-- app/pages/direct.vue:直す前 -->
<script setup lang="ts">
const items = await $fetch('/api/items')
</script>
<template>
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
</template>
<!-- app/pages/usefetch.vue:直した後 -->
<script setup lang="ts">
const { data: items, status } = await useFetch('/api/items')
</script>
<template>
<p>status: {{ status }}</p>
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
</template>
npm run build のあと PORT=7443 node .output/server/index.mjs で起動し、Playwright(Chromium)で各ページを開いて、サーバーの標準出力とブラウザの /api/ へのリクエストを記録しました。
実行結果
### 1. /direct を直接開く($fetch を直接 await)
サーバーログ:
[api] #1 GET /api/items page=1 呼び出し元=SSR(内部呼び出し)
[api] #2 GET /api/items page=1 呼び出し元=ブラウザ(HTTP)
ブラウザのネットワーク(/api/):
GET /api/items
### 2. /usefetch を直接開く(useFetch)
サーバーログ:
[api] #3 GET /api/items page=1 呼び出し元=SSR(内部呼び出し)
ブラウザのネットワーク(/api/):
(なし)
表示: status: success / 備品A(p1) / 備品B(p1)
### 3. /asyncdata を直接開く(useAsyncData + $fetch)
サーバーログ:
[api] #4 GET /api/items page=1 呼び出し元=SSR(内部呼び出し)
ブラウザのネットワーク(/api/):
(なし)
$fetch を直接使ったページだけ、ブラウザからもう1回呼ばれています。 APIの負荷が倍になるだけでなく、SSRとブラウザで結果が違うとハイドレーション不一致(表示のずれ)の原因にもなります。
ページ遷移(NuxtLink)で移動した場合
トップページから <NuxtLink> で移動したときは、SSRを通らないため、どちらの書き方でもブラウザから1回でした。
### 4. / から NuxtLink で /direct へ移動
サーバーログ:
[api] #5 GET /api/items page=1 呼び出し元=ブラウザ(HTTP)
### 5. / から NuxtLink で /usefetch へ移動
サーバーログ:
[api] #6 GET /api/items page=1 呼び出し元=ブラウザ(HTTP)
違いが出るのは「URLを直接開いたとき(初回表示・再読み込み)」です。検索エンジンや共有リンクから来た人は必ずこの経路を通るため、画面表示に使うデータは useFetch か useAsyncData で取得するのが基本です。
useFetch と useAsyncData の書き換え対応
useFetch(url, options) は、ほぼ useAsyncData(key, () => $fetch(url, options)) と同じ意味です。 useFetch はキーをURLとオプションから自動で作ります。
// この2つは同じ結果になる
const { data: a } = await useFetch('/api/items', { query: { page: 1 } })
const { data: b } = await useAsyncData('items-page-1', () =>
$fetch('/api/items', { query: { page: 1 } }),
)
同じページで a と b を JSON.stringify で比べると「同じ」と表示されました。
useAsyncData が向いているのは、$fetch 以外の処理や、複数の結果をまとめるときです。
// 2つのAPIを並列で取得して1つのデータにする
const { data } = await useAsyncData('dashboard', async () => {
const [items, counter] = await Promise.all([
$fetch('/api/items'),
$fetch('/api/counter'),
])
return { items, counter }
})
表示: 2件 / total=10(SSRで取得)
使い分けフロー
flowchart TD
A[データを取得したい] --> B{画面の表示に使う?}
B -- いいえ(ボタンで送信など) --> C[$fetch]
B -- はい --> D{1つのURLを呼ぶだけ?}
D -- はい --> E[useFetch]
D -- いいえ(複数API・SDK・DB) --> F[useAsyncData]
E --> G{取得を待たずにページを切り替えたい?}
F --> G
G -- はい --> H[useLazyFetch / useLazyAsyncData]
G -- いいえ --> I[そのまま await]
| 場面 | 使うもの |
|---|---|
| ページを開いたときに一覧を表示 | useFetch |
| 検索条件(ref)が変わったら取り直す | useFetch の query に ref を渡す |
| 複数のAPIをまとめて1つのデータに | useAsyncData |
| 「登録」ボタンでPOST | $fetch |
| 別のコンポーネントで同じデータを読む | useNuxtData(key) |
| 登録後に一覧を取り直す | refreshNuxtData(key) または refresh() |
主要オプション一覧
Nuxt 4.5.2 の型定義(asyncData.d.ts・fetch.d.ts)で確認した値です。 useFetch では、これに加えて query・method・body・headers・baseURL など $fetch(ofetch)のオプションも使えます。
| オプション | 既定値 | 内容 |
|---|---|---|
key |
自動(useFetchはURLとオプションから、useAsyncData は呼び出し位置から) | データを共有・キャッシュするための名前 |
server |
true |
SSRで取得するか。false ならブラウザだけで取得 |
lazy |
false |
true だとページ遷移を止めずに、表示後に取得 |
immediate |
true |
false だと自動では取得しない(execute() で開始) |
watch |
- | 指定したリアクティブな値が変わったら再取得(useFetch は false で無効化も可) |
transform |
- | 取得結果を加工してから data に入れる |
pick |
- | 取得結果から指定したキーだけ残す(transform と併用しない) |
default |
- | 取得が終わるまでの初期値を返す関数 |
getCachedData |
payloadにあれば使う | キャッシュを返す関数。undefined を返すと取得する |
dedupe |
'cancel' |
同じキーの取得が重なったとき、前を取り消す('defer' は待つ) |
timeout |
- | 指定ミリ秒で取得を打ち切る |
deep |
false |
true で data を深いリアクティブにする |
enabled |
true |
false の間は取得処理を動かさない |
戻り値
| 値 | 内容 |
|---|---|
data |
取得結果(Nuxt 4 では shallowRef、初期値 undefined) |
status |
'idle'・'pending'・'success'・'error' |
pending |
status === 'pending' と同じ真偽値 |
error |
失敗時の NuxtError(初期値 undefined) |
refresh() / execute() |
再取得する |
clear() |
data などを初期状態に戻し、実行中の取得を取り消す |
同じオプションを何度も書く場合は、4.2.0 で追加された createUseFetch で、既定値を決めた独自の useFetch を作れます。
Nuxt 4 で変わった挙動
公式のアップグレードガイドで確認した、データ取得まわりの主な変更です。
| 項目 | Nuxt 3 | Nuxt 4 |
|---|---|---|
data の種類 |
深いリアクティブな ref |
shallowRef(deep: true で従来どおり) |
data・error の初期値 |
null |
undefined |
| 読み込み中の判定 | pending を使うことが多い |
status(pending は status === 'pending' と同期) |
immediate: false の pending |
最初から true |
最初は false |
| 同じキーの呼び出し | それぞれ別の ref | data・error・status を共有。オプションが違うと警告 |
refresh({ dedupe }) |
true / false |
'cancel' / 'defer' |
getCachedData |
(key, nuxtApp) |
(key, nuxtApp, ctx)。ctx.cause で呼ばれた理由がわかる |
| 使われなくなったデータ | 残る | 最後のコンポーネントが外れると自動で消える |
shallowRef の違いを確かめる
shallowRef は、data.value を丸ごと入れ替えたときだけ画面が更新されます。 中のプロパティだけを書き換えても、画面は自動では変わりません。
// server/api/counter.get.ts
export default defineEventHandler(() => ({ total: 10, nested: { value: 1 } }))
<!-- app/pages/shallow.vue -->
<script setup lang="ts">
const { data: a } = await useFetch('/api/counter', { key: 'shallow-a' })
const { data: b } = await useFetch('/api/counter', { key: 'shallow-b', deep: true })
function incA() {
a.value!.total++ // 中のプロパティだけを書き換える
}
function incB() {
b.value!.total++
}
function replaceA() {
a.value = { ...a.value!, total: a.value!.total + 1 } // 丸ごと置き換える
}
</script>
<template>
<p id="a">A(既定・shallowRef): {{ a?.total }}</p>
<p id="b">B(deep: true): {{ b?.total }}</p>
<button id="incA" @click="incA">A.total++</button>
<button id="incB" @click="incB">B.total++</button>
<button id="replaceA" @click="replaceA">Aを置き換え</button>
</template>
Playwright でボタンを順に押した結果です。
A.total++ した後: A(既定・shallowRef): 10
B.total++ した後: A(既定・shallowRef): 11 | B(deep: true): 11
Aを置き換えた後: A(既定・shallowRef): 12
A の total++ では表示が10のままでした。値そのものは11になっていて、B の更新で画面が描き直されたときに11と表示されています。中身を書き換えたいときは「丸ごと置き換える」か deep: true にすると、意図どおりに表示が変わります。
値の変化に合わせて再取得する(query・watch)
useFetch の query や URL に ref を渡すと、その値が変わるたびに自動で再取得されます。
<!-- app/pages/paging.vue -->
<script setup lang="ts">
const page = ref(1)
// query に ref を渡すと、page が変わるたびに自動で再取得される
const { data: items, status } = await useFetch('/api/items', {
query: { page },
})
</script>
<template>
<p>page: {{ page }} / status: {{ status }}</p>
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
<button @click="page++">次のページ</button>
</template>
「次のページ」を2回押した結果です。初回はSSR、2回目以降はブラウザから取得されています。
サーバーログ:
[api] #7 GET /api/items page=1 呼び出し元=SSR(内部呼び出し)
[api] #8 GET /api/items page=2 呼び出し元=ブラウザ(HTTP)
[api] #9 GET /api/items page=3 呼び出し元=ブラウザ(HTTP)
ブラウザのネットワーク(/api/):
GET /api/items?page=2
GET /api/items?page=3
表示: page: 3 / status: success / 備品A(p3) / 備品B(p3)
useAsyncData の場合は、watch: [page] を指定すると同じように再取得できます。
エラー処理(error と createError)
取得に失敗すると data は undefined、status は 'error' になり、error に NuxtError が入ります。
// server/api/fail.get.ts
export default defineEventHandler(() => {
throw createError({ statusCode: 404, statusMessage: 'Item Not Found', data: { reason: '在庫データがありません' } })
})
<!-- app/pages/error-demo.vue -->
<script setup lang="ts">
const { data, error, status } = await useFetch('/api/fail')
</script>
<template>
<p id="status">status: {{ status }}</p>
<p id="data">data: {{ data === undefined ? 'undefined' : data }}</p>
<p v-if="error" id="error">
{{ error.statusCode }} / {{ error.statusMessage }} / {{ error.data?.data?.reason }}
</p>
</template>
表示: status: error / data: undefined / 404 / Item Not Found / 在庫データがありません
error.data にはAPIが返したエラーのJSONが入り、createError の data はその中の data に入っていました。 取得できなかったらページ全体をエラー画面にしたい場合は、セットアップで createError を投げます。
<!-- app/pages/fatal.vue -->
<script setup lang="ts">
const { data, error } = await useFetch('/api/fail')
if (error.value) {
// 取得に失敗したらエラーページを表示する
throw createError({ statusCode: error.value.statusCode, statusMessage: error.value.statusMessage })
}
</script>
<template>
<p>{{ data }}</p>
</template>
/fatal status: 404 | 404 / Item Not Found / Item Not Found / Go back home
app/error.vue を置いていないため、Nuxt 標準のエラー画面が 404 で表示されました。
キャッシュして再取得を減らす(getCachedData)
既定では、ページ遷移のたびにブラウザから取得し直します。 getCachedData で取得済みのデータを返すと、2回目以降のリクエストを省けます。
<!-- app/pages/cache.vue -->
<script setup lang="ts">
const { data: items } = await useFetch('/api/items', {
key: 'cached-items',
// 取得済みのデータがあれば、それを返してリクエストを省く
getCachedData(key, nuxtApp, ctx) {
if (ctx.cause === 'refresh:manual') return undefined // 手動 refresh では取り直す
return nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]
},
})
</script>
<template>
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
<NuxtLink to="/">戻る</NuxtLink>
</template>
| 操作(ブラウザ内の遷移) | ブラウザの /api/ リクエスト |
|---|---|
| 既定の useFetch:/ → /usefetch → 戻る → /usefetch | 2回 |
| getCachedData あり:/ → /cache → / → /cache | 1回 |
ctx.cause は 'initial'・'refresh:hook'・'refresh:manual'・'watch' のいずれかです。「更新ボタンのときだけ取り直す」といった調整ができます。
useNuxtData・refreshNuxtData・clearNuxtData
キーを付けて取得したデータは、別のコンポーネントからキーで読み書きできます。
<!-- app/pages/tools.vue -->
<script setup lang="ts">
// 'items' というキーで取得
await useAsyncData('items', () => $fetch('/api/items'))
</script>
<template>
<ItemsSummary />
<button id="refresh" @click="refreshNuxtData('items')">refreshNuxtData('items')</button>
<button id="clear" @click="clearNuxtData('items')">clearNuxtData('items')</button>
</template>
<!-- app/components/ItemsSummary.vue -->
<script setup lang="ts">
// 取得はせず、キャッシュ済みのデータだけを読む
const { data: items } = useNuxtData('items')
</script>
<template>
<p id="summary">件数: {{ items ? items.length : 'データなし' }}</p>
</template>
初期表示: 件数: 2
refreshNuxtData 後: 件数: 2 / ブラウザのリクエスト: [ '/api/items' ]
clearNuxtData 後: 件数: データなし
refreshNuxtData はブラウザから1回取り直し、clearNuxtData は data を初期値(undefined)に戻しました。
キーの衝突に注意する
Nuxt 4 では、同じキーの useAsyncData は data を共有します。 別のAPIに同じキーを付けてしまうと、2つ目も1つ目のデータを表示します。
<!-- app/pages/collision.vue:直す前 -->
<script setup lang="ts">
const { data: items } = await useAsyncData('list', () => $fetch('/api/items'))
const { data: counter } = await useAsyncData('list', () => $fetch('/api/counter'))
</script>
開発サーバー(nuxt dev)で開くと、次の警告が出て、counter にも items の配列が入りました。
[NUXT_E3004] Incompatible options detected for "list":
- different handler
├▶ fix: You can use a different key or move the call to a composable to ensure the options are shared across calls.
╰▶ sources: http://localhost:7444/_nuxt/pages/collision.vue:11:76
直し方:キーを分けます。 'list:items'・'list:counter' にすると警告は消え、counter には { "total": 10, "nested": { "value": 1 } } が表示されました。キーは「画面名:データ名」のように、意味がわかる名前にそろえると衝突を防げます。
useLazyFetch と status で読み込み中を表示する
useLazyFetch は、ブラウザ内のページ遷移で取得を待たずに画面を切り替えます。 URLを直接開いたときは、SSRで取得まで済ませます。
<!-- app/pages/lazy.vue -->
<script setup lang="ts">
const { data: items, status } = useLazyFetch('/api/items', { key: 'lazy-items' })
</script>
<template>
<p id="status">status: {{ status }}</p>
<p v-if="status === 'pending'">読み込み中…</p>
<ul v-else>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
</template>
### 9. /lazy を直接開く(useLazyFetch)
サーバーログ:
[api] #10 GET /api/items page=1 呼び出し元=SSR(内部呼び出し)
ブラウザのネットワーク(/api/):
(なし)
表示: status: success / 備品A(p1) / 備品B(p1)
Nuxt 4 では、読み込み中の判定に status === 'pending' を使うと、idle(未開始)とも区別できて便利です。
ボタン操作での送信は $fetch
クリックなどのイベントの中では、useFetch ではなく $fetch を使います。 useFetch はセットアップで呼ぶ前提の関数だからです。
<!-- app/pages/post.vue -->
<script setup lang="ts">
const name = ref('プロジェクター')
const result = ref<{ id: number; name: string }>()
async function submit() {
result.value = await $fetch('/api/items', { method: 'POST', body: { name: name.value } })
}
</script>
<template>
<input v-model="name" />
<button @click="submit">登録</button>
<p id="result">{{ result ? `登録しました: ${result.id} ${result.name}` : '未登録' }}</p>
</template>
表示: 登録しました: 99 プロジェクター
ブラウザ: POST http://localhost:7443/api/items
サーバーログ: [api] POST /api/items name=プロジェクター
登録後に一覧も取り直したいときは、続けて refreshNuxtData('items') を呼びます。
よくある質問
Q. useFetch と useAsyncData はどちらを使えばよいですか?
A. 1つのURLを呼ぶだけなら useFetch が短く書けます。複数のAPIをまとめる、SDKを使うなど $fetch 以外の処理があるときは useAsyncData を使います。
Q. $fetch はセットアップで使ってはいけないのですか?
A. 使えますが、URLを直接開いたときにSSRとブラウザで2回呼ばれます(本記事で実測)。画面の表示に使うデータなら useFetch か useAsyncData で包むと1回になります。
Q. Nuxt 3 から上げたら、data.value.xxx = ... で画面が変わらなくなりました。
A. Nuxt 4 では data が shallowRef になったためです。丸ごと置き換えるか、deep: true を指定してください。
Q. pending はもう使えませんか?
A. 使えます。Nuxt 4 では status === 'pending' と同じ値になります。idle と区別したいときは status が便利です。
まとめ
- useFetch は「useAsyncData + $fetch」の短縮形、useAsyncData は任意の非同期処理を包む汎用版です。
- セットアップで
$fetchを直接使うと、URLを直接開いたときに2回呼ばれます(SSR 1回+ブラウザ 1回、実測)。 - useFetch・useAsyncData なら1回。SSRの結果がブラウザへ引き継がれます。
- Nuxt 4 では
dataがshallowRef、初期値はundefined。中身の書き換えは丸ごと置き換えかdeep: true。 - 同じキーはデータを共有します。別のデータには別のキーを付けましょう。
getCachedDataでページ遷移時の再取得を減らせます(実測で2回→1回)。- ボタン操作の送信は
$fetch、取り直しはrefresh()かrefreshNuxtData()です。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Nuxt useFetchとuseAsyncDataの違い|リクエスト回数を実測」
株式会社RJCでは、データ取得の仕組みを実際に動かして確かめる進め方を、プロジェクトの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。