🔥

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>
  )
}

データフローの詳細

全体的なデータフローを詳しく見てみましょう:

ベストプラクティス

  1. キャッシュ戦略の設計

    • loaderでプリフェッチしたデータは、適切なstaleTimeを設定してキャッシュを活用
    • 頻繁に更新されるデータは短めのstaleTimeを設定
  2. エラーハンドリング

    • loaderレベルでのエラーはerrorComponentで処理
    • コンポーネントレベルでのエラーはErrorBoundaryで処理
  3. パフォーマンス最適化

    • 必要なデータのみをloaderで取得
    • 重いデータは遅延ロードを検討

まとめ

TanStack RouterのloaderとTanStack Queryを組み合わせることで、以下のメリットが得られます。

  • ルート遷移時の高速なデータ表示
  • 型安全なクエリパラメータの管理
  • Suspenseを使った洗練されたローディング体験
  • 効率的なキャッシュ管理による再フェッチの最小化

この実装パターンを採用することで、ユーザー体験の向上と開発効率の改善を同時に実現できます。特に、大規模なアプリケーションでは、この統合的なアプローチが真価を発揮します。
TanStackを利用してSPAを作成する時に参考にしてみてください

chot Inc. tech blog

Discussion