2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Nuxt 4における実践的 `useFetch` 設計パターン:リアクティビティとキャッシュ戦略の完全ガイド

Posted at

はじめに

Nuxtの useFetch を勉強がてらツールを作ってみました。
useFetch とても便利なんですが、リアクティブなパラメータと正確なキャッシュキーの管理 をちゃんとやらないと、以下のような問題にハマります(ました)

  • パラメータ変更の都度データがフェッチされてしまう
  • リクエスト内容変えたのに前のキャッシュが返ってくる
  • 複数パラメータの変更で無駄なリクエストが連発する

私の趣味開発(Github活動量可視化ツール)では、
Custom Composables でuseFetchをラップして、リアクティビティとキャッシュ戦略を構造レベルで解決 する設計パターンを採用しましたので学習メモも兼ねてその解説をします。


1. アーキテクチャの全体像

責務の分離:

  1. Component: UIとフィルタ入力に集中
  2. Composable: リアクティビティ管理、キャッシュキー生成、API隠蔽
  3. Server API: バリデーション(Zod)、外部API通信、レスポンス整形
  4. 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) だけで queryParamsfetchKey が自動更新される
  • 複数フィルタを連続変更してもリクエストは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 ラッパーパターン は、実際にこれらの問題にハマった経験から生まれた解決策です。初期構築は少し手間ですが、中長期的な保守性は劇的に向上します。

同じような課題に直面している方の参考になれば幸いです。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?