概要
Server Component で prefetchQuery を実行し、HydrationBoundary 経由でクライアントコンポーネントに TanStack Query のキャッシュを渡す。初期ロード時のクライアントフェッチ(ウォーターフォール)をなくし、SSR でデータを埋め込んだ状態でハイドレートする。
インストール
npm install @tanstack/react-query
実装
QueryClient のセットアップ(サーバー用)
// lib/getQueryClient.ts
import { QueryClient } from "@tanstack/react-query";
import { cache } from "react";
// Server Component 向けにリクエストごとに新しいインスタンスを生成
export const getQueryClient = cache(() => new QueryClient());
クライアント用 Provider
// app/providers.tsx
"use client";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
// app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
データ取得関数
// lib/fetchPosts.ts
export type Post = { id: number; title: string; body: string };
export async function fetchPosts(): Promise<Post[]> {
const res = await fetch(
"https://jsonplaceholder.typicode.com/posts?_limit=10"
);
if (!res.ok) throw new Error("fetch failed");
return res.json();
}
export async function fetchPost(id: number): Promise<Post> {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
if (!res.ok) throw new Error("fetch failed");
return res.json();
}
Server Component でプリフェッチ
// app/posts/page.tsx
import {
dehydrate,
HydrationBoundary,
} from "@tanstack/react-query";
import { getQueryClient } from "@/lib/getQueryClient";
import { fetchPosts } from "@/lib/fetchPosts";
import { PostList } from "./PostList";
export default async function PostsPage() {
const queryClient = getQueryClient();
// サーバー側でキャッシュを先読み
await queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
return (
// dehydrate でキャッシュをシリアライズして渡す
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
);
}
クライアントコンポーネント(キャッシュがあるため初期フェッチなし)
// app/posts/PostList.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
import { fetchPosts, type Post } from "@/lib/fetchPosts";
export function PostList() {
const { data: posts, isLoading } = useQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
// HydrationBoundary からキャッシュが渡されるため、
// 初期レンダリング時は isLoading が false になる
});
if (isLoading) return <p>読み込み中...</p>;
return (
<main className="mx-auto max-w-xl p-8">
<h1 className="mb-6 text-2xl font-bold">記事一覧</h1>
<ul className="space-y-2">
{posts?.map((post: Post) => (
<li key={post.id} className="rounded border p-3 text-sm text-gray-700">
{post.title}
</li>
))}
</ul>
</main>
);
}
ポイント
getQueryClientをcache()でラップすることで、同一リクエスト内では同じQueryClientインスタンスを使い回せる(リクエストをまたいで共有されない)prefetchQueryで Server Component 側でデータを取得しキャッシュに入れる。dehydrateでシリアライズして HTML に埋め込むHydrationBoundaryがクライアント側でキャッシュを復元(ハイドレート)するため、useQueryの初回レンダリング時にフェッチが走らない- 既存の
tanstack-query-data-fetchingとの違い: クライアントのみのフェッチでは初期表示にローディング状態が出るが、プリフェッチパターンでは HTML 時点でデータが含まれる staleTimeを設定するとクライアント側での再フェッチタイミングを制御できる(staleTime: 60 * 1000で 1分間はキャッシュを有効として再フェッチしない)