TanStack Query — 毎日手動でやっているデータフェッチの悩みを解決する
useEffect + useState でデータを取得しているなら、この記事はあなたのためです。
その方法が間違っているわけではありません。ただ、本来やらなくていいことを自分でたくさんやっている、ということです。
useEffect + useState の問題点
ほとんどの人は、最初こんなコードを書きます:
function ProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetch('/api/products', { signal: controller.signal })
.then(res => res.json())
.then(data => {
setProducts(data);
setLoading(false);
})
.catch(err => {
if (err.name === 'AbortError') return; // アンマウント時は無視
setError(err);
setLoading(false);
});
return () => controller.abort(); // アンマウント時のクリーンアップ
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>エラーが発生しました</div>;
return (
<div className="grid grid-cols-3 gap-4">
{products.map(p => (
<ProductCard key={p.id} name={p.name} price={p.price} />
))}
</div>
);
}
一見問題なさそうですが、これはすでに正しく書けているバージョンです。AbortController を忘れると、コンポーネントがアンマウントされた後もリクエストが裏で走り続け、存在しないコンポーネントに対して setState を呼んでしまい、メモリリークにつながります。
商品一覧を取得するだけなのに、これだけのことを自分でやっています:
-
data/loading/errorの 3 つの state を自前で管理 - メモリリーク防止のための
AbortControllerのクリーンアップ - どのタイミングで再フェッチするかの判断
- 同じコンポーネントが 2 箇所にマウントされると → API が 2 回呼ばれる(同じデータなのに共有できない)
- 別ページに遷移して戻ってくると → また最初から API を呼び直す(キャッシュなし)
これはバグではありません。まだ実装していないフィーチャーです。
TanStack Query とは?
TanStack Query(旧称:React Query)は、サーバーステートを管理するライブラリです。サーバーステートとは、API から取得するデータのことです。
Zustand や Redux の代替ではありません(それらはクライアントステートを管理するものです)。TanStack Query が解決するのは別の問題、すなわち「サーバーからのデータのフェッチ・キャッシュ・同期・更新」です。
インストール
npm install @tanstack/react-query
npm install @tanstack/react-query-devtools # キャッシュのデバッグに役立つので一緒に入れておくのがおすすめ
アプリを QueryClientProvider でラップし、開発環境では ReactQueryDevtools も追加します:
// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} /> {/* プロダクションビルドでは自動的に非表示 */}
</QueryClientProvider>
);
Devtools を使うと、どのキャッシュが存在するか、fresh な状態か stale な状態か、いつ再フェッチが発生するかを視覚的に確認できます。デバッグ時に非常に便利です。
useQuery — 基本的なデータフェッチ
最もよく使うフックです。先ほどの ProductList を TanStack Query で書き直してみましょう:
import { useQuery } from '@tanstack/react-query';
function ProductList() {
const { data: products, isLoading, error } = useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then(res => res.json()),
});
if (isLoading) return <ProductSkeleton />;
if (error) return <div>商品を読み込めませんでした</div>;
return (
<div className="grid grid-cols-3 gap-4">
{products.map(p => (
<ProductCard key={p.id} name={p.name} price={p.price} />
))}
</div>
);
}
コードが短くなりました。ただ、本当に重要なのはコードの量ではなく、裏側で何が起きているかです:
-
自動キャッシュ:初回フェッチが完了すると、データは
queryKeyに紐づいてキャッシュされます。別のコンポーネントが同じキーを使っても、API は追加で呼ばれません。 - スマートな再フェッチ:ブラウザのタブにフォーカスが戻ったタイミングで自動的に再フェッチします。
- 重複排除(Deduplication):3 つのコンポーネントが同時にマウントされて同じクエリを使っていても、API の呼び出しは 1 回だけです。
queryKey — 最も重要なポイント
queryKey はクエリを識別するための配列です。各データの種類に対する「ユニークな名前」だと考えてください。
// 全商品を取得
useQuery({ queryKey: ['products'], queryFn: fetchProducts });
// ID で商品詳細を取得 — 商品ごとに独立したキャッシュを持つ
useQuery({ queryKey: ['products', productId], queryFn: () => fetchProduct(productId) });
// カテゴリと価格フィルターで商品を取得
useQuery({
queryKey: ['products', { category: 'shoes', maxPrice: 5000 }],
queryFn: () => fetchProductsByFilter({ category: 'shoes', maxPrice: 5000 }),
});
シンプルなルール:データがパラメータによって変わるなら、そのパラメータを queryKey に含める。
useMutation — データの変更
データの作成・更新・削除には useMutation を使います:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function AddToCartButton({ productId }: { productId: string }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (item: { productId: string; quantity: number }) =>
fetch('/api/cart', {
method: 'POST',
body: JSON.stringify(item),
}).then(res => res.json()),
onSuccess: () => {
// カートに追加後 → キャッシュを無効化 → バッジの数量が自動更新される
queryClient.invalidateQueries({ queryKey: ['cart'] });
},
onError: () => {
// エラーは必ず処理する — ユーザーがクリックしたのに何も起きないのは最悪のUX
toast.error('カートへの追加に失敗しました。もう一度お試しください。');
},
});
return (
<button
onClick={() => mutation.mutate({ productId, quantity: 1 })}
disabled={mutation.isPending}
>
{mutation.isPending ? '追加中...' : 'カートに追加'}
</button>
);
}
invalidateQueries は最も重要なパターンです:mutation が成功した後、['cart'] のキャッシュが古くなったことを TanStack Query に伝えると、自動的に再フェッチが行われます。
よく使うオプション
// 商品詳細ページ
useQuery({
queryKey: ['products', productId],
queryFn: () => fetchProduct(productId),
// 商品価格は頻繁に変わらないのでタブフォーカス時の再フェッチは不要
refetchOnWindowFocus: false,
// 10分間はデータを「新鮮」とみなす → 同じ queryKey なら再フェッチしない
// 実際のキャッシュはさらに長く残る場合あり(gcTime で制御、デフォルトは未使用から5分)
staleTime: 1000 * 60 * 10,
// API エラー時に 2 回リトライ(ネットワークが不安定な場合に有効)
retry: 2,
// productId がある場合のみフェッチ — undefined な ID で API を呼ばないようにする
enabled: !!productId,
// 再フェッチ中もスケルトンを表示せず古いデータを表示する — UX がよりスムーズになる
placeholderData: (prev) => prev,
});
enabled は、クエリが条件に依存する場合に特に便利です。たとえば productId が取得できてから商品詳細をフェッチする、といったケースで undefined のまま API を呼んでしまうのを防げます。
まとめ
TanStack Query は魔法ではありません。あなたがすでに手動でやっていることを、より正確に・一貫した方法でやってくれるだけです。
| 自前で実装していたこと | TanStack Query が担当 |
|---|---|
useState で loading / error / data を管理 |
isLoading / error / data がそのまま使える |
| マウントのたびに API を呼び直す | データをキャッシュして再利用 |
| 複数コンポーネントでの重複フェッチ | 自動で重複排除(Deduplication) |
| いつ再フェッチするかを自前で決める | フォーカス・インターバルに応じてスマートに再フェッチ |
| mutation 後の状態同期 |
invalidateQueries で解決 |
アプリに API フェッチが 2〜3 箇所以上あるなら、TanStack Query によってコードはシンプルになり、バグは減り、ユーザー体験は向上します。しかも、あまり深く考えなくても。
次回:実際のプロジェクトにおける query 関数とカスタムフックの整理方法
