naoki.dev

記事検索

タイトル・本文・タグから検索します

ホームに戻る
フロントエンド

Next.js(App Router / 静的エクスポート)に TanStack Query を導入する手順

目次

App Router の静的エクスポート(output: "export")なプロジェクトに TanStack Query(React Query) を入れました。SPA なのでデータ取得はすべてクライアント側。「Provider をどこに置く」「fetch をどう共通化する」「hooks をどう分ける」を、実際に組んだ順にまとめます。

結論の構成はこう:

app/
├── lib/
│   ├── tanstack/index.tsx   # QueryClient の Provider("use client")
│   └── api.ts               # 共通 fetch
├── hooks/
│   └── useHealth.ts         # 1ファイル1フック。queryFn は api 関数を呼ぶだけ
└── layout.tsx               # Provider でラップ

1. インストール

pnpm add @tanstack/react-query
# 任意(開発時のデバッグUI)
pnpm add @tanstack/react-query-devtools

React 19 に対応した v5 が入ります。

2. Provider を Client Component で初期化する

QueryClientProvider は Context を使うので、Client Component"use client")で用意します。ポイントは QueryClientuseState の初期化関数で1回だけ生成すること。モジュールトップで new QueryClient() するとインスタンスが共有・再生成されて事故るので避けます。

// app/lib/tanstack/index.tsx
"use client";
 
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode, useState } from "react";
 
const Providers = ({ children }: { children: ReactNode }) => {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000, // 1分は再フェッチしない
            gcTime: 5 * 60 * 1000,
            retry: 1,
            refetchOnWindowFocus: false,
          },
          mutations: {
            retry: 0,
          },
        },
      }),
  );
 
  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
 
export default Providers;

ReactNodeReact.ReactNode ではなく named import にしています。React 17+ の自動 JSX ランタイムで import React が不要になったので、React 名前空間に依存しない書き方に揃える方がモダンです。

defaultOptions のよく使う項目

オプション 意味
staleTime 何 ms 鮮度を保つ(この間は再フェッチしない)
gcTime 未使用キャッシュを破棄するまでの時間(旧 cacheTime
retry 失敗時のリトライ回数
refetchOnWindowFocus フォーカス復帰で再フェッチ(既定 true。うるさいので切ることが多い)

3. layout でラップ

layout.tsx(Server Component のままでOK)で、<body> の中を Provider で包みます。Client Component の Provider を Server Component から描画するのは問題ありません。

// app/layout.tsx
import type { ReactNode } from "react";
 
import Providers from "./lib/tanstack";
 
const RootLayout = ({ children }: Readonly<{ children: ReactNode }>) => {
  return (
    <html lang="ja">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
};
 
export default RootLayout;

4. 共通 fetch を切り出す(lib/api.ts)

queryFn に毎回 fetch + エラー処理 + base URL を書くのは冗長なので、共通関数を1つ用意します。非 2xx を throw しておくと、TanStack Query 側が自動で error 状態にしてくれます。

// app/lib/api.ts
const BASE = process.env.NEXT_PUBLIC_API_ENDPOINT;
 
// Content-Type は body があるときだけ付ける(GET に付けると余計なプリフライトが飛ぶため)。
export const apiFetch = async <T>(path: string, init?: RequestInit): Promise<T> => {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: {
      ...(init?.body ? { "Content-Type": "application/json" } : {}),
      ...init?.headers,
    },
  });
  if (!res.ok) throw new Error(`API ${res.status}: ${path}`);
  return res.json() as Promise<T>;
};

base URL は NEXT_PUBLIC_ プレフィックスの env(ビルド時に埋め込まれる)で渡します。静的エクスポートではサーバーが無いので、これで十分です。

5. hooks は「1ファイル1フック」、queryFn は api 関数を呼ぶだけ

useQuery を直接コンポーネントに書くと再利用しづらいので、hooks ディレクトリに1ファイル1フックで切り出します。queryFn には fetch ロジックを持たせず、apiFetch を呼ぶだけにします。

// app/hooks/useHealth.ts
"use client";
 
import { useQuery } from "@tanstack/react-query";
 
import { apiFetch } from "@/lib/api";
 
type Health = { message: string };
 
export const useHealth = () =>
  useQuery({
    queryKey: ["health"],
    queryFn: () => apiFetch<Health>("/health"),
  });

queryKey は「データ単位」でユニークに

queryKey はキャッシュのキーです。関数ごとではなく取得するデータ(リソース+パラメータ)ごとにユニークにします。

  • 同じデータ → 同じキーにする(別コンポーネントで呼んでもキャッシュ共有&リクエスト重複排除)
  • パラメータ付き → キーに含める(["items", id] のように)。入れ忘れると id を変えても古いキャッシュが返る
["items"]        // 一覧
["items", id]    // 詳細(id ごとに別キャッシュ)

先頭にエンティティ名を置いておくと、invalidateQueries({ queryKey: ["items"] }) で前方一致の一括無効化がしやすくなります。

6. コンポーネントで使う

hooks を呼ぶコンポーネントは Client Component("use client")です。isLoading / error / data を出し分けます。

"use client";
 
import { useHealth } from "@/hooks/useHealth";
 
export const Health = () => {
  const { data, isLoading, error } = useHealth();
 
  if (isLoading) return <p>loading...</p>;
  if (error) return <p>error</p>;
  return <p>{data?.message}</p>;
};

この Client Component は、page.tsx(Server Component)から描画(JSX で配置)するのはOKです。フックを Server Component で直接呼ぶのは NG(フックはクライアントでしか動かない)なので、「呼ぶ場所」と「描画する場所」を分けるのがコツです。

静的エクスポートでの注意

  • サーバーが無いので、SSR プリフェッチ/hydration(HydrationBoundary/dehydrate)は使わず、クライアント専用でシンプルに。
  • ビルド時プリレンダーでは useQuery は loading 状態の HTML になり、ブラウザで hydrate 後にフェッチ→表示、という流れ。

今後の増やし方

「1ファイル1フック」を維持すると増えても迷いません。

  • クエリ追加 → app/hooks/useItems.ts / useItem.ts
  • ミューテーション → app/hooks/useCreateItem.ts(中で apiFetch("/items", { method: "POST", body })

fetch の実処理は lib/api.ts に集約されているので、hooks は「キー」と「どの api を呼ぶか」だけの薄い層に保てます。

まとめ

  • Provider は Client ComponentQueryClientuseState 初期化で1回だけ生成
  • layout.tsx<body> を Provider でラップ(layout 自体は Server Component のまま)
  • 共通 fetch を lib/api.ts に切り出し、非 2xx は throw
  • hooks は1ファイル1フックqueryFn は api 関数を呼ぶだけの薄い層に
  • queryKeyデータ単位でユニーク、パラメータはキーに含める
  • 静的エクスポートはクライアント専用でシンプルに

参考ソース

関連記事