TanStack RouterとTanStack Queryを組み合わせたモダンなデータフェッチング戦略
TanStack RouterとTanStack Queryを組み合わせたモダンなデータフェッチング戦略
はじめに
モダンなReactアプリケーションでは、ルーティングとデータフェッチングの統合が重要な課題となっています。TanStack RouterのloaderとTanStack Queryを組み合わせることで、効率的なデータ取得と状態管理を実現できます。本記事では、この2つのライブラリを連携させる実践的な方法について解説します。
アーキテクチャの概要
TanStack RouterとTanStack Queryを組み合わせたアーキテクチャでは、以下のような流れでデータを管理します。
環境設定
まず、必要なパッケージをインストールします。
npm install @tanstack/react-router @tanstack/react-query valibot
validateSearchを使ったクエリパラメータの検証
TanStack Routerでは、validateSearch
を使用してURLのクエリパラメータを型安全に扱えます。valibotを使用した実装例を見てみましょう。
import { createFileRoute } from '@tanstack/router'
import * as v from 'valibot'
// 検索パラメータのスキーマ定義
const searchSchema = v.object({
page: v.optional(v.pipe(v.string(), v.transform(Number)), 1),
filter: v.optional(v.string(), ''),
sortBy: v.optional(v.union([v.literal('name'), v.literal('date')]), 'name')
})
export const Route = createFileRoute('/products')({
validateSearch: searchSchema,
})
loaderを使ったデータ取得
TanStack Routerのloaderは、ルート遷移時にデータを事前に取得する仕組みです。ここで取得したデータをTanStack Queryのキャッシュに設定します。
import { createFileRoute } from '@tanstack/router'
import { queryClient } from '../queryClient'
// APIフェッチ関数
const fetchProducts = async (params: { page: number; filter: string }) => {
const response = await fetch(
`/api/products?page=${params.page}&filter=${params.filter}`
)
if (!response.ok) throw new Error('Failed to fetch products')
return response.json()
}
// クエリキーの生成関数
const productsQueryOptions = (params: { page: number; filter: string }) => ({
queryKey: ['products', params],
queryFn: () => fetchProducts(params),
staleTime: 1000 * 60 * 5, // 5分間はデータを新鮮とみなす
})
export const Route = createFileRoute('/products')({
validateSearch: (search) => v.parse(searchSchema, search),
loader: async ({ context, search }) => {
const queryOptions = productsQueryOptions({
page: search.page,
filter: search.filter,
})
// データをプリフェッチしてキャッシュに保存
const products = await context.queryClient.ensureQueryData(queryOptions)
return { products }
},
})
pendingComponentとerrorComponentの実装
TanStack Routerでは、データ取得中やエラー時の表示を専用コンポーネントで管理できます。
export const Route = createFileRoute('/products')({
validateSearch: (search) => v.parse(searchSchema, search),
loader: async ({ context, search }) => {
// loader実装(前述)
},
// ローディング中の表示
pendingComponent: () => (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
<span className="ml-3">商品を読み込んでいます...</span>
</div>
),
// エラー時の表示
errorComponent: ({ error }) => (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<h3 className="text-red-800 font-medium">エラーが発生しました</h3>
<p className="text-red-600 mt-2">{error.message}</p>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
再試行
</button>
</div>
),
component: ProductsPage,
})
TanStack QueryでSuspenseを使った部分更新
コンポーネント内でTanStack Queryを使用し、loaderで取得したデータを初期値として活用します。
import { useSuspenseQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/router'
import { Suspense } from 'react'
const route = getRouteApi('/products')
function ProductsPage() {
const search = route.useSearch()
const loaderData = route.useLoaderData()
return (
<div>
<h1>商品一覧</h1>
<Suspense fallback={<ProductListSkeleton />}>
<ProductList
initialData={loaderData.products}
search={search}
/>
</Suspense>
</div>
)
}
function ProductList({ initialData, search }) {
// loaderのデータを初期値として使用
const { data: products } = useSuspenseQuery({
queryKey: ['products', { page: search.page, filter: search.filter }],
queryFn: () => fetchProducts({ page: search.page, filter: search.filter }),
initialData: initialData,
staleTime: 1000 * 60 * 5,
})
return (
<div className="grid grid-cols-3 gap-4">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
function ProductListSkeleton() {
return (
<div className="grid grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-48 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded mt-2 w-3/4" />
<div className="h-4 bg-gray-200 rounded mt-2 w-1/2" />
</div>
))}
</div>
)
}
部分更新の実装パターン
Suspenseを活用した部分更新により、UIの一部だけを更新できます。
function ProductsPageWithFilters() {
const search = route.useSearch()
const navigate = route.useNavigate()
const handleFilterChange = (newFilter: string) => {
navigate({
search: (prev) => ({ ...prev, filter: newFilter, page: 1 })
})
}
return (
<div>
<FilterSection onFilterChange={handleFilterChange} />
{/* フィルター変更時、この部分だけが再レンダリング */}
<Suspense fallback={<ProductListSkeleton />}>
<ProductList search={search} />
</Suspense>
{/* ページネーションも独立してSuspense */}
<Suspense fallback={<div>ページ情報を読み込み中...</div>}>
<Pagination search={search} />
</Suspense>
</div>
)
}
データフローの詳細
全体的なデータフローを詳しく見てみましょう:
ベストプラクティス
-
キャッシュ戦略の設計
- loaderでプリフェッチしたデータは、適切な
staleTime
を設定してキャッシュを活用 - 頻繁に更新されるデータは短めの
staleTime
を設定
- loaderでプリフェッチしたデータは、適切な
-
エラーハンドリング
- loaderレベルでのエラーは
errorComponent
で処理 - コンポーネントレベルでのエラーは
ErrorBoundary
で処理
- loaderレベルでのエラーは
-
パフォーマンス最適化
- 必要なデータのみをloaderで取得
- 重いデータは遅延ロードを検討
まとめ
TanStack RouterのloaderとTanStack Queryを組み合わせることで、以下のメリットが得られます。
- ルート遷移時の高速なデータ表示
- 型安全なクエリパラメータの管理
- Suspenseを使った洗練されたローディング体験
- 効率的なキャッシュ管理による再フェッチの最小化
この実装パターンを採用することで、ユーザー体験の向上と開発効率の改善を同時に実現できます。特に、大規模なアプリケーションでは、この統合的なアプローチが真価を発揮します。
TanStackを利用してSPAを作成する時に参考にしてみてください

ちょっと株式会社(chot-inc.com)のエンジニアブログです。 フロントエンドエンジニア募集中! カジュアル面接申し込みはこちらから chot-inc.com/recruit/iuj62owig
Discussion