【Nuxt.js】useFetchがSSR/CSRで二重に実行されてハマった話

JavaScript

こんにちは、かつコーチです。

先日、案件で作っていた管理画面で、useFetchが同じAPIを2回叩いていることに気づきました。

ネットワークタブを見ても1回しか通信していないのに、サーバーログには同じリクエストが2件記録されていました。

原因を追うのに丸1日かかったので、同じ罠にハマる人を減らすために詳しく記録しておきます。

useFetchuseAsyncDataの内部動作をある程度理解している前提で、原因と対処法を掘り下げます。

症状の再現

問題のコード

発端は、こんな何の変哲もないコンポーネントでした。

<!-- pages/users/[id].vue -->
<script setup lang="ts">
const route = useRoute();

const { data: user } = await useFetch(`/api/users/${route.params.id}`);

console.log("useFetch called", route.params.id);
</script>

<template>
  <div v-if="user">
    <h1>{{ user.name }}</h1>
    <p>{{ user.email }}</p>
  </div>
</template>

一見どこにも問題がなさそうに見えますが、実際に開発サーバーとブラウザのコンソールを見比べると、次のようなログが残ります。

# サーバー側ターミナル
useFetch called 42

# ブラウザのコンソール
useFetch called 42

サーバー側で1回、クライアント側のハイドレーション時にもう1回、合計2回console.logが実行されていました。

APIサーバーのアクセスログでも、同一のGET /api/users/42が2件並んでいました。

[server] GET /api/users/42 200 - 18ms
[server] GET /api/users/42 200 - 21ms

体感では気づきにくいのですが、外部APIが従量課金だったこともあり、無駄なリクエストとして表面化しました。

原因調査

SSR用ペイロードが引き継がれない条件

NuxtのuseFetchは、本来SSR時に取得したデータをpayloadとしてクライアントに引き継ぎ、ハイドレーション時に再フェッチしない仕組みになっています。

つまり正常に動いていれば、サーバーで1回取得したデータをそのままクライアントが使い回すはずです。

今回の原因は、keyが動的に変わる状況で、Nuxtが内部的に生成する自動keyとSSR時のpayloadキーがずれてしまっていたことでした。

具体的には、動的ルートのパラメータをテンプレートリテラルでURLに埋め込む書き方が引き金でした。

// key指定なし。URLの文字列から自動でkeyが生成される
const { data: user } = await useFetch(`/api/users/${route.params.id}`);

このプロジェクトでは、ミドルウェアでroute.params.idをリクエストごとに正規化する処理を入れていました。

サーバー側とクライアント側で、正規化前後のタイミングにわずかなズレがあり、生成される自動keyが両者で微妙に異なっていたのです。

# サーバー側で生成されたkey
$xewSRGZTeM

# クライアント側で生成されたkey
$Nx8ptFAksd

Nuxtは、SSR時のpayloadとクライアント側のkeyが一致しない場合、「payloadが見つからない」と判断してクライアント側で再度フェッチします。

これが、同じAPIが2回呼ばれていた正体でした。

なぜ気づきにくいのか

このズレは、useFetchのURLが単純な固定文字列であれば発生しません。

動的パラメータをURLに直接埋め込み、なおかつサーバー・クライアント間でパラメータの解決タイミングに差が出るケースに限って再現します。

ローカル開発では気づきにくく、ステージング環境でリクエスト数が多いときにようやくAPIサーバーのログから発覚する、というのが今回の落とし穴でした。

Before/Afterでの対処法

Before/After 1: keyを明示的に指定する

最も確実な対処法は、keyオプションを自分で明示的に指定することです。

// ❌ Before: keyを自動生成に任せている
const { data: user } = await useFetch(`/api/users/${route.params.id}`);
// ✅ After: keyを明示的に固定する
const { data: user } = await useFetch(`/api/users/${route.params.id}`, {
  key: `user-${route.params.id}`,
});

keyを固定することで、サーバー側とクライアント側で同じキーを参照するようになり、payloadが正しく引き継がれます。

修正後、サーバーログもブラウザのコンソールも1回ずつしか出力されなくなりました。

# サーバー側ターミナル
useFetch called 42

# ブラウザのコンソール
(何も出力されない = 再フェッチが発生していない)

Before/After 2: パラメータの正規化はサーバー側に寄せる

根本原因だった「正規化タイミングのズレ」自体も見直しました。

// ❌ Before: ミドルウェアでパラメータをクライアント・サーバー両方で正規化
export default defineNuxtRouteMiddleware((to) => {
  to.params.id = normalizeId(to.params.id as string);
});
// ✅ After: 正規化はサーバーAPI側(server/api)だけで行い、
// クライアント側はroute.paramsをそのまま使う
// server/api/users/[id].get.ts
export default defineEventHandler((event) => {
  const rawId = getRouterParam(event, "id");
  const id = normalizeId(rawId ?? "");
  return getUserById(id);
});

こうすることで、クライアント側のroute.params.idは加工されないため、keyに使う値がサーバー・クライアントで完全に一致するようになりました。

Before/After 3: useAsyncDataで取得ロジックを共通化する

複数箇所で似たフェッチ処理を書いていたことも、keyのズレを招きやすくしていました。

// ❌ Before: 各コンポーネントで個別にuseFetchを書く
// pages/users/[id].vue
const { data: user } = await useFetch(`/api/users/${route.params.id}`);

// components/UserCard.vue(別コンポーネントでも同じAPIを呼ぶ)
const { data: user } = await useFetch(`/api/users/${props.userId}`);
// ✅ After: composablesにまとめてkeyの生成ルールを一元化する
// composables/useUser.ts
export function useUser(userId: string) {
  return useFetch(`/api/users/${userId}`, {
    key: `user-${userId}`,
  });
}
<!-- pages/users/[id].vue -->
<script setup lang="ts">
const route = useRoute();
const { data: user } = useUser(route.params.id as string);
</script>

keyの生成ルールを1箇所に集約したことで、以後は同じ落とし穴を踏まなくなりました。

応用:二重実行を検知する仕組みを仕込む

開発環境限定でリクエスト回数を可視化する

同じ問題を早期に発見できるよう、開発環境限定でリクエスト回数を数える仕組みをserver/api側に仕込みました。

// server/middleware/log-request.ts
export default defineEventHandler((event) => {
  if (process.env.NODE_ENV !== "production") {
    console.log(`[req] ${event.node.req.method} ${event.node.req.url}`);
  }
});

このログを開発中に眺める習慣をつけておくと、同一URLが短時間に連続して出力された時点で異常に気づけます。

watchと組み合わせるときはさらに注意する

useFetchwatchオプションと組み合わせて再取得のトリガーを増やす場合、keyの設計をより慎重に行う必要があります。

watch対象が変わるたびに新しいリクエストが発生するのは意図通りですが、それが「二重実行」なのか「意図した再取得」なのかを切り分けられるように、ログにwatch対象の値も一緒に出力しておくと調査が楽になります。

まとめ

この記事のポイント

  • useFetchはSSR時のデータをpayloadとして引き継ぎ、本来はクライアントで再フェッチしない
  • URLの動的パラメータから自動生成されるkeyがサーバー・クライアントでズレると、payloadが見つからず二重実行される
  • 対処の基本はkeyオプションを明示的に固定すること
  • パラメータの正規化処理はサーバー・クライアントで分岐させず、片側(サーバーAPI側)に寄せる
  • フェッチロジックをcomposablesに一元化すると、key設計のブレを防げる

次に読むべき記事

次回は、composables・server・pages・layoutsといったディレクトリ構成のベストプラクティスを、Next.jsとの対比で整理します。

タグ: #Nuxt.js #上級者向け #エラー解決

タイトルとURLをコピーしました