TanStack Query による高度なデータ取得 - 最適化された更新、ページネーション、WebSocket 統合
Ethan Miller
Product Engineer · Leapcell

はじめに
現代の Web 開発において、パフォーマンスが高く、応答性があり、データリッチなユーザーインターフェースを構築することは最優先事項です。ユーザーはシームレスなインタラクションと最新の情報を期待しています。従来のデータ取得メカニズムは単純なシナリオでは十分な場合が多いですが、複雑なアプリケーションの要求には、より洗練されたソリューションが必要です。ここで TanStack Query (旧 React Query) のようなライブラリが真価を発揮します。サーバー状態を管理するための強力なプリミティブを提供し、データ取得、キャッシング、同期、エラー処理を大幅に簡素化します。その基本的な機能を超えて、TanStack Query はユーザーエクスペリエンスと開発者生産性を真に向上させる高度な機能スイートを提供します。この記事では、これらの重要な機能のうち 3 つ、つまり即時フィードバックのための最適化された更新、大規模データセットを処理するための効率的なページネーション戦略、リアルタイムデータ同期のための WebSocket とのシームレスな統合について掘り下げます。これらの高度なパターンを理解し活用することで、アプリケーションは単に機能的なものから、喜ばしいほど応答性が高くダイナミックなものへと変貌させることができます。
コアコンセプト
高度な機能に入る前に、この議論全体で参照される TanStack Query におけるいくつかのコアコンセプトを簡単に定義しましょう。
- Query (クエリ): バックエンドからのデータ取得リクエストを表します。クエリは一意の
queryKey
によって識別され、TanStack Query によって自動的にキャッシュおよび再取得されます。 - Mutation (ミューテーション): バックエンドのデータを変更する操作 (例: 作成、更新、削除) を表します。ミューテーションはしばしば副作用を持ち、クエリの無効化をトリガーできます。
- QueryClient (クエリクライアント): すべてのクエリとミューテーションを管理する中心的なインスタンスです。キャッシュを保持し、それと対話するためのメソッドを提供します。
- QueryCache (クエリキャッシュ): TanStack Query がクエリの結果とそのメタデータを保存する場所です。この永続的なキャッシュにより、以前に取得したデータを即座にレンダリングできます。
- Invalidation (無効化): キャッシュされたクエリを「古い」とマークするプロセスであり、次にアクセスされたときに TanStack Query にバックグラウンドで再取得するように促します。これにより、データの鮮度が保証されます。
これらの基本的な概念により、TanStack Query はアプリケーションのサーバー状態をインテリジェントに管理し、より高度な動作を実装するための堅牢なプラットフォームを提供します。
最適化された更新 (Optimistic Updates)
最適化された更新は、アプリケーションの知覚パフォーマンスと応答性を向上させるための強力なテクニックです。UI を更新する前にサーバー応答を待つ代わりに、最適化された更新は期待される変更を即座に UI に適用します。サーバー操作が成功した場合、UI は更新されたままになります。失敗した場合、UI は以前の状態にロールバックされます。これにより、ユーザーに即時のフィードバックが提供され、アプリケーションがはるかに高速に感じられます。
仕組み
最適化された更新の核心的な考え方は、クライアント側で「成功を仮定する」ことです。ミューテーションが開始されると、次の手順を実行します。
- 最適化された更新と干渉する可能性のある送信中のクエリをキャンセルします。
- **ミューテーションの影響を受ける現在のクエリデータをスナップショットします。**これにより、ミューテーションが失敗した場合に円滑なロールバックが可能になります。
- ミューテーションの期待される結果を反映するようにキャッシュされたクエリデータを直接変更して、UI を最適化された方法で更新します。
- サーバーで実際のミューテーションを実行します。
- 成功時: 影響を受けたクエリを無効化して、サーバーから最新のデータを再取得し、一貫性を確保します。
- エラー時: ステップ 2 で取得したスナップショットに UI をロールバックします。
実装例
ユーザーが todo アイテムの「完了」ステータスを切り替えるシナリオを考えてみましょう。
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { updateTodoApi } from './api'; // これが API 呼び出しであると仮定 function TodoItem({ todo }) { const queryClient = useQueryClient(); const { mutate } = useMutation({ mutationFn: updateTodoApi, onMutate: async (newTodo) => { // ステップ 1: todos クエリの送信中の再取得をすべてキャンセルする await queryClient.cancelQueries({ queryKey: ['todos'] }); // ステップ 2: 前の値のスナップショットを取得する const previousTodos = queryClient.getQueryData(['todos']); // ステップ 3: キャッシュを段階的に更新する queryClient.setQueryData(['todos'], (old) => old ? old.map((t) => (t.id === newTodo.id ? newTodo : t)) : [] ); // スナップショットを含むコンテキストオブジェクトを返す return { previousTodos }; }, onError: (err, newTodo, context) => { // ステップ 6: エラー時にロールバックする queryClient.setQueryData(['todos'], context.previousTodos); console.error('Failed to update todo:', err); }, onSettled: () => { // ステップ 5: ミューテーション後に最新のデータを確保するためにクエリを無効化する queryClient.invalidateQueries({ queryKey: ['todos'] }); }, }); const toggleComplete = () => { mutate({ ...todo, completed: !todo.completed }); }; return ( <div> <input type="checkbox" checked={todo.completed} onChange={toggleComplete} /> <span>{todo.title}</span> </div> ); }
この例では、ユーザーがチェックボックスをクリックすると、リクエストが成功したかのように UI が即座に更新されます。updateTodoApi
の呼び出しに失敗した場合、UI は前の状態に円滑に復元されます。これにより、サーバーラウンドトリップを待つよりも大幅にスムーズなユーザーエクスペリエンスが提供されます。
アプリケーションシナリオ
最適化された更新は、即時の視覚的なフィードバックが重要であり、失敗の可能性が比較的低いアクションに最適です。一般的なシナリオには次のものがあります。
- チェックボックスの切り替え (例: todo の完了、未読としてマーク)。
- 投稿のいいね/いいね解除。
- カートへのアイテムの追加/削除 (在庫の問題に対する円滑なエラー処理を含む)。
- 即時確認が役立つ単純なフォーム送信。
ページネーションクエリ (Pagination Queries)
大規模なデータセットを効果的に処理することは、Web 開発における一般的な課題です。一度に数千件のレコードを表示することは非効率的であり、パフォーマンスを損なう可能性があります。ページネーションは広く採用されているソリューションであり、ユーザーがデータを管理しやすいチャンクで閲覧できるようにします。TanStack Query は、さまざまなページネーション戦略を実装するための堅牢な機能を提供し、効率的なデータ取得とスムーズなユーザーエクスペリエンスを保証します。
ページネーションの種類
ページネーションには主に 2 つの種類があります。
- オフセットベースのページネーション: これは最も一般的な形式であり、
page
番号とlimit
(またはper_page
) に基づいてデータを要求します。サーバーは、合計リストからの特定の「オフセット」のエントリを返します。 - カーソルベースのページネーション (無限スクロール): このメソッドは、以前に取得したセットからの「カーソル」(通常は ID またはタイムスタンプ) に基づいてデータを要求します。これは、新しいデータがユーザーが下にスクロールするにつれて追加される無限スクロールエクスペリエンスでよく使用されます。
useQuery
を使用したオフセットベースのページネーション
標準的なページごとのナビゲーションの場合、useQuery
は完全に適しています。
import { useQuery } from '@tanstack/react-query'; import { fetchPostsApi } from './api'; // API がページごとに投稿を取得すると仮定 function PostsList() { const [page, setPage] = useState(0); const { data, isPreviousData, isLoading, isError, error } = useQuery({ queryKey: ['posts', page], // queryKey はページ番号とともに変化する queryFn: () => fetchPostsApi(page), keepPreviousData: true, // 新しいデータがロードされている間、以前のデータを保持する }); if (isLoading) return <div>Loading posts...</div>; if (isError) return <div>Error: {error.message}</div>; return ( <div> {data.posts.map((post) => ( <div key={post.id}>{post.title}</div> ))} <button onClick={() => setPage((old) => Math.max(old - 1, 0))} disabled={page === 0} > Previous </button> <button onClick={() => { if (!isPreviousData && data.hasMore) { // API が hasMore を返すことを確認する setPage((old) => old + 1); } }} disabled={isPreviousData || !data.hasMore} > Next </button> <span>Current Page: {page + 1}</span> </div> ); }
queryKey
に page
を追加することで、TanStack Query は各ページのデータをキャッシュ内の個別のエントリとして扱います。keepPreviousData: true
オプションはここで重要です。これにより、新しいページのデータがロードされている間、以前に取得したデータが可視のままになり、 jarring な空白状態を防ぎ、ユーザーエクスペリエンスを向上させます。
useInfiniteQuery
を使用したカーソルベースのページネーション
無限スクロールまたは「さらに読み込む」パターンの場合、useInfiniteQuery
が最適なソリューションです。これは、時間とともに成長するリストをフェッチおよび管理するために特別に設計されています。
import { useInfiniteQuery } from '@tanstack/react-query'; import { fetchCommentsApi } from './api'; // API が 'cursor' でコメントを取得すると仮定 function CommentsFeed() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError, error, } = useInfiniteQuery({ queryKey: ['comments'], queryFn: ({ pageParam }) => fetchCommentsApi(pageParam), // pageParam はカーソル initialPageParam: undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, // API は nextCursor を返す必要がある }); if (isLoading) return <div>Loading comments...</div>; if (isError) return <div>Error: {error.message}</div>; return ( <div> {data.pages.map((page, i) => ( <React.Fragment key={i}> {page.comments.map((comment) => ( <div key={comment.id}>{comment.text}</div> ))} </React.Fragment> ))} <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} > {isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Load More' : 'Nothing more to load'} </button> </div> ); }
useInfiniteQuery
は、ページごとにグループ化されたフラットな配列にデータを格納します。getNextPageParam
は、TanStack Query に次のフェッチリクエストの pageParam
(通常はサーバー応答 (lastPage.nextCursor
) によって提供されるカーソル) を取得する方法を指示する重要な関数です。このパターンは、必要なときにのみ新しいデータをフェッチするため、初期ロード時間とサーバー負荷を削減するため、非常に効率的です。
アプリケーションシナリオ
- オフセットベースのページネーション: 管理ダッシュボード、検索結果、固定ページ番号付きの e コマース製品リスト。
- カーソルベースのページネーション: ソーシャルメディアフィード、アクティビティログ、チャット履歴、あらゆる「無限スクロール」エクスペリエンス。
WebSocket 統合
TanStack Query は RESTful API からのサーバー状態の管理に優れていますが、最新のアプリケーションは多くの場合 WebSocket を介したリアルタイム更新を必要とします。WebSocket を TanStack Query と統合することで、ポーリングや手動再取得を頻繁に行うことなく、リアルタイムの変更を直接クエリキャッシュにプッシュできます。
課題
適切な統合がない場合、WebSocket からのリアルタイム更新は、TanStack Query で管理されているデータに自動的に反映されない場合があります。通常、コンポーネント状態を更新するか、再取得をトリガーする必要があり、不整合や boilerplate コードにつながります。
queryClient.setQueryData
および queryClient.invalidateQueries
を使用したソリューション
WebSocket を統合する鍵は、queryClient.setQueryData
を使用してキャッシュを直接更新し、queryClient.invalidateQueries
を使用して適切なタイミングで再取得をトリガーすることです。
import React, { useEffect } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { fetchStockPricesApi } from './api'; // 初期株価を取得する API // WebSocket 接続ユーティリティを仮定 const connectWebSocket = (onMessage) => { const ws = new WebSocket('ws://localhost:8080/stock-prices'); ws.onmessage = (event) => onMessage(JSON.parse(event.data)); return ws; }; function StockPricesDisplay() { const queryClient = useQueryClient(); // 初期株価を取得する const { data: stockPrices, isLoading, isError, error } = useQuery({ queryKey: ['stockPrices'], queryFn: fetchStockPricesApi, }); useEffect(() => { const ws = connectWebSocket((newPrice) => { // キャッシュ内の特定の株価を更新する queryClient.setQueryData(['stockPrices'], (oldPrices) => { if (!oldPrices) return [newPrice]; // キャッシュが空の場合、初期状態を処理する return oldPrices.map((price) => price.symbol === newPrice.symbol ? newPrice : price ); }); // 他のデータに対する完全な再取得が必要な場合は、オプションでクエリを無効化する // 例: 個々の株価から計算される「ポートフォリオ合計」 // queryClient.invalidateQueries({ queryKey: ['portfolioTotal'] }); }); return () => ws.close(); // アンマウント時に WebSocket 接続をクリーンアップする }, [queryClient]); if (isLoading) return <div>Loading stock prices...</div>; if (isError) return <div>Error: {error.message}</div>; return ( <div> <h2>Real-time Stock Prices</h2> {stockPrices.map((stock) => ( <div key={stock.symbol}> {stock.symbol}: ${stock.price.toFixed(2)} <span style={{ color: stock.change > 0 ? 'green' : 'red' }}> ({stock.change > 0 ? '+' : ''} {stock.change.toFixed(2)}%) </span> </div> ))} </div> ); }
この例では、
- コンポーネントがマウントされたときに WebSocket 接続を確立します。
- WebSocket から新しいメッセージ (例: 更新された株価) を受信すると、それを解析します。
- 次に、
queryClient.setQueryData(['stockPrices'], ...)
を使用して、キャッシュ内のstockPrices
クエリを直接更新します。この変更は、このクエリを使用するすべてのコンポーネントの再レンダリングを即座にトリガーし、リアルタイム更新を反映します。 - オプションで、受信した WebSocket イベントが派生データ (例: アイテムリストから計算される合計) に影響を与える場合、
queryClient.invalidateQueries
を使用して、これらの依存クエリの再取得をトリガーできます。
このアプローチにより、WebSocket のリアルタイム機能と TanStack Query の堅牢な状態管理を組み合わせる強力な方法が提供され、最小限の手動状態処理で優れたユーザーエクスペリエンスが提供されます。
アプリケーションシナリオ
- リアルタイムダッシュボード: 株価ティッカー、暗号通貨価格、ライブ分析。
- チャットアプリケーション: 即時メッセージ配信。
- 通知: ユーザーへのリアルタイム通知のプッシュ。
- 共同編集: 複数ユーザー間での変更の同期。
結論
TanStack Query は、基本的なデータ取得を超えて、複雑なサーバー状態を管理するための強力なツールのエコシステムを提供します。最適化された更新、洗練されたページネーション、WebSocket 統合などの高度な機能に習熟することで、開発者は効率的で堅牢なだけでなく、並外れて応答性が高くダイナミックなアプリケーションを作成できます。これらのテクニックは、知覚パフォーマンスと優れたユーザーエクスペリエンスに大きく貢献し、今日の要求の厳しいデジタルランドスケープでアプリケーションが際立つようにします。TanStack Query により、自信と容易さをもって、非常に魅力的なリアルタイムユーザーインターフェースを構築できます。