はじめに
Nuxtの useFetch を勉強がてらツールを作ってみました。
useFetch とても便利なんですが、リアクティブなパラメータと正確なキャッシュキーの管理 をちゃんとやらないと、以下のような問題にハマります(ました)
- パラメータ変更の都度データがフェッチされてしまう
- リクエスト内容変えたのに前のキャッシュが返ってくる
- 複数パラメータの変更で無駄なリクエストが連発する
私の趣味開発(Github活動量可視化ツール)では、
Custom Composables でuseFetchをラップして、リアクティビティとキャッシュ戦略を構造レベルで解決 する設計パターンを採用しましたので学習メモも兼ねてその解説をします。
1. アーキテクチャの全体像
責務の分離:
- Component: UIとフィルタ入力に集中
- Composable: リアクティビティ管理、キャッシュキー生成、API隠蔽
- Server API: バリデーション(Zod)、外部API通信、レスポンス整形
- Types: Client/Server間の共有型定義
2. よくある失敗パターン
失敗例 1: キャッシュキーの不備
// ❌ 固定キー → 異なるパラメータでも同じキャッシュが返される
const { data: dataA } = useFetch('/api/data', {
key: 'data', // 固定!
query: { repo: 'repo-a', month: '2024-01' },
})
const { data: dataB } = useFetch('/api/data', {
key: 'data', // 同じキー!
query: { repo: 'repo-b', month: '2024-02' },
})
// → dataB には dataA のキャッシュが返される 😱
// ❌ 配列が正規化されていない → 順序違いで別キー扱い
const repos = ref(['repo-a', 'repo-b'])
const { data } = useFetch('/api/data', {
key: `data-${repos.value.join(',')}`, // 順序依存
query: { repo: repos.value },
})
repos.value = ['repo-b', 'repo-a'] // 順序変更
// → 無駄なリクエストが発生
失敗例 2: 複数フィルタの同時変更で無駄なリクエスト
// ❌ 個別の ref → 変更のたびにリクエスト
const month = ref('2024-01')
const repo = ref('repo-a')
const team = ref('team-x')
const { data } = useFetch('/api/data', {
query: { month, repo, team },
// デフォルトで watch: true → 各変更で再フェッチ
})
// 3つ同時に変更すると...
const resetFilters = () => {
month.value = currentMonth() // → リクエスト1
repo.value = defaultRepo() // → リクエスト2
team.value = defaultTeam() // → リクエスト3
}
↑↑ レート制限があるAPIだと致命的。
3. 解決策:Composable パターン
3.1. リアクティブなフィルタ管理
export const useReviewChecklist = () => {
// 1. フィルタを reactive で管理(初期値を設定)
const filters = reactive<ReviewChecklistFilters>({
month: currentMonth(),
rangeMonths: 1,
excludeWords: '',
excludeSelfComments: true,
})
// 2. computed でクエリパラメータを自動生成
const queryParams = computed(() => ({
month: filters.month,
rangeMonths: filters.rangeMonths,
excludeWords: filters.excludeWords,
excludeSelfComments: filters.excludeSelfComments,
}))
// 3. useFetchKey でキャッシュキーを自動生成
const fetchKey = useFetchKey('review-checklist', queryParams)
// 4. useFetch に渡す
const { data, pending, error, refresh } = useFetch<ReviewChecklistResponse>(
'/api/review-checklist',
{
key: fetchKey,
query: queryParams,
watch: false, // 手動実行(ボタン押下時)
lazy: false,
retry: 2,
},
)
return { filters, data, pending, error, refresh }
}
ページでの使用:
<script setup lang="ts">
const { filters, data, refresh } = useReviewChecklist()
// フィルタ更新(自動で queryParams と fetchKey が再評価される)
const applyFilterChange = (patch: Partial<ReviewChecklistFilters>) => {
Object.assign(filters, patch)
// watch: false なので、ここでは再フェッチされない
}
</script>
<template>
<ReviewChecklistFilters :filters="filters" @change="applyFilterChange" @refresh="refresh" />
</template>
ポイント:
-
Object.assign(filters, patch)だけでqueryParamsとfetchKeyが自動更新される - 複数フィルタを連続変更してもリクエストは0回
-
refresh()呼び出し時に最新の値で1回だけフェッチ
3.2. キャッシュキー自動生成(useFetchKey)
各Composableでキー生成ロジックを実装すると、ユニークの責任を実装者の肩に背負わせることになるので、共通ヘルパーに集約しました。
// composables/useFetchKey.ts
export const useFetchKey = (prefix: string, queryParams: ComputedRef<Record<string, unknown>>) => {
return computed(() => {
const params = queryParams.value
const normalizedParams = Object.entries(params)
.map(([key, value]) => {
// 配列はソートして正規化
if (Array.isArray(value)) {
return `${key}:${[...value].sort().join(',')}`
}
return `${key}:${String(value)}`
})
.sort() // キーの順序も正規化
.join('|')
return `${prefix}:${normalizedParams}`
})
}
使い方:
const queryParams = computed(() => ({ month: filters.month, repo: filters.repo }))
const fetchKey = useFetchKey('review-checklist', queryParams)
メリット:
- ✅ DRY:
queryParamsが唯一の情報源になる(重複なし) - ✅ 安全: 手動でキーを組み立てる必要なし
- ✅ 保守性: フィルタ追加時は
queryParamsだけ変更すればOK - ✅ 正規化: 配列順序やキー順序の差異を自動で吸収してくれる
3.3. watch オプションの使い分け
このプロジェクトは watch: false(手動実行)を採用してますが、watch: true に変更するだけで Auto-fetch に切り替わります。
| オプション | 動作 | 用途 |
|---|---|---|
watch: true |
フィルタ変更で即座再フェッチ | リアルタイム検索、ダッシュボード |
watch: false |
ボタン押下で手動実行 | 検索ボタン方式、レート制限対策 |
4. Server Side: バリデーションとキャッシュ
Zod による入力検証
// server/api/review-checklist.get.ts
import { z } from 'zod'
const querySchema = z.object({
owner: z.string().optional(),
repo: z.union([z.string(), z.array(z.string())]).optional(),
month: z
.string()
.regex(/^\d{4}-(0[1-9]|1[0-2])$/)
.optional(),
})
export default defineCachedEventHandler(
async (event) => {
const parsed = querySchema.safeParse(getQuery(event))
if (!parsed.success) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid query parameters',
cause: parsed.error,
})
}
const data = parsed.data
// ...GitHub API 呼び出し...
return {
/* ... */
} satisfies ReviewChecklistResponse
},
{
maxAge: 60,
swr: false,
},
)
まとめ
Nuxtの useFetch は便利なんですが、使い方を間違えると無駄なリクエストを連発したり、キャッシュキーの管理が煩雑になったりします。
この記事で紹介した Composable ラッパーパターン は、実際にこれらの問題にハマった経験から生まれた解決策です。初期構築は少し手間ですが、中長期的な保守性は劇的に向上します。
同じような課題に直面している方の参考になれば幸いです。