React状態管理の深掘り #2 TanStack Queryで扱うサーバー状態
#1では、状態をクライアント状態とサーバー状態に分けました。この記事はそのうちサーバー状態を扱います。投稿一覧、ユーザー情報、検索結果のように、サーバーに原本があり、画面は複製を見せているだけのデータです。
サーバー状態の標準ツールはTanStack Queryです。かつてReact Queryという名前で知られ、今はReact以外のフレームワークも支援するようになり、TanStack Queryに名前が変わりました。この記事のコードはv5基準です。
useEffectで書いていた方式の問題 #
まず、基礎講座 #10で学んだおなじみの方式を思い出してみます。
function PostList() {
const [posts, setPosts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setIsLoading(true);
fetch("/api/posts")
.then((res) => {
if (!res.ok) throw new Error("リクエスト失敗");
return res.json();
})
.then((data) => setPosts(data))
.catch((err) => setError(err))
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <p>読み込み中...</p>;
if (error) return <p>エラー: {error.message}</p>;
return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}動きはします。ところがこのコードには欠けているものが多いです。
- キャッシュがありません。 このコンポーネントを離れて戻ると、毎回最初から取得し直します。
- 重複リクエストを防げません。 同じデータを2つのコンポーネントが使うと、リクエストが2回出ます。
- 鮮度管理がありません。 一度取得したデータが古くなったかどうかを知る手立てがありません。
- 状態変数3つ(データ、ローディング、エラー)を毎回手で管理する必要があります。
これらすべてを標準で提供するのがTanStack Queryです。
インストールとProvider設定 #
npm install @tanstack/react-queryアプリの最上部をQueryClientProviderで包みます。QueryClientが1つで、すべてのキャッシュを管理します。
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);useQuery — データを読む #
上のPostListをTanStack Queryで書き直すと、こうなります。
import { useQuery } from "@tanstack/react-query";
function PostList() {
const { data, isPending, isError, error } = useQuery({
queryKey: ["posts"],
queryFn: async () => {
const res = await fetch("/api/posts");
if (!res.ok) throw new Error("リクエスト失敗");
return res.json();
},
});
if (isPending) return <p>読み込み中...</p>;
if (isError) return <p>エラー: {error.message}</p>;
return <ul>{data.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}状態変数3つが消え、useQuery1回に整理されました。渡すのは2つだけです。
queryKey— このデータを識別するキーです。["posts"]のように配列で書きます。このキーがキャッシュの核心です。 同じキーを持つリクエストはキャッシュを共有し、重複リクエストは1つにまとまります。queryFn— 実際にデータを取得する非同期関数です。Promiseを返しさえすれば、fetchでもaxiosでも構いません。
これでこのコンポーネントを離れて戻っても、キャッシュにあったデータがすぐ表示され、バックグラウンドで静かに最新データを確認し直します。
queryKeyに変数を入れる #
特定の投稿1件を取得するなら、キーにidを一緒に入れます。
function PostDetail({ postId }) {
const { data, isPending } = useQuery({
queryKey: ["posts", postId],
queryFn: async () => {
const res = await fetch(`/api/posts/${postId}`);
return res.json();
},
});
if (isPending) return <p>読み込み中...</p>;
return <article><h1>{data.title}</h1><p>{data.body}</p></article>;
}postIdが変わるとqueryKeyも変わり、TanStack Queryはこれを別のデータと認識して自動的に取得し直します。そして各idごとの結果を別々にキャッシュします。useEffectの依存配列に気を配っていたことが、queryKeyに自然に吸収されます。
staleTime — どれくらい新鮮とみなすか #
#1で、サーバー状態は放っておくと古くなると述べました。TanStack Queryは取得したデータを一定時間「新鮮(fresh)」とみなし、その時間が過ぎると「古い(stale)」と印を付けます。古いデータは画面にそのまま見せつつ、適切なタイミング(ウィンドウに再びフォーカスが来たときなど)にバックグラウンドで取得し直します。
useQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
staleTime: 60 * 1000, // 60秒間は取得し直さない
});staleTimeの既定値は0なので、既定の設定ではデータを受け取った瞬間に古いとみなし、頻繁に確認し直します。あまり変わらないデータなら、staleTimeを伸ばして不要なリクエストを減らせます。
staleTimeと混同しやすい値にgcTime(ガベージコレクション時間、既定5分)があります。staleTimeは「いつ取得し直すか」を、gcTimeは「画面で使われなくなったキャッシュをいつメモリから捨てるか」を決めます。v5で、かつてのcacheTimeがgcTimeに名前が変わりました。useMutation — データを変える #
読みがuseQueryなら、書き(作成・更新・削除)はuseMutationです。新しい投稿を登録する例です。
import { useMutation, useQueryClient } from "@tanstack/react-query";
function NewPostForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (newPost) => {
const res = await fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newPost),
});
return res.json();
},
onSuccess: () => {
// 一覧キャッシュを無効化して自動で取得し直させる
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
const title = e.target.title.value;
mutation.mutate({ title });
}}
>
<input name="title" />
<button disabled={mutation.isPending}>
{mutation.isPending ? "登録中..." : "登録"}
</button>
</form>
);
}ここで最も重要な行は、onSuccessの中のinvalidateQueriesです。投稿を追加したあと["posts"]キーのキャッシュを無効化すると、そのデータを見ていたすべてのコンポーネントが自動的に最新の一覧を取得し直します。 手で一覧の状態を更新する必要はありません。これが、サーバー状態を一箇所で一貫して管理するということの実際の姿です。
何が消えたかの整理 #
useEffect方式と比べて、TanStack Queryが自動で処理してくれるものです。
| 項目 | useEffect + useState | TanStack Query |
|---|---|---|
| ローディング・エラー状態 | 手で変数管理 | isPending / isErrorを提供 |
| キャッシュ | なし | queryKey基準で自動 |
| 重複リクエストの統合 | なし | 自動 |
| ウィンドウフォーカス時の更新 | 自前で実装 | 標準で提供 |
| 変更後の一覧更新 | 手で状態を修正 | invalidateQueries1行 |
まとめ #
サーバー状態はTanStack Queryに任せれば、キャッシュと再取得、鮮度管理が付いてきます。
useQuery({ queryKey, queryFn })— 読み、キャッシュとローディング・エラー状態が自動queryKey— キャッシュを識別するキー、変数を入れてパラメータ別にキャッシュstaleTime— データをどれくらい新鮮とみなすかuseMutation+invalidateQueries— 書きのあと関連キャッシュを無効化して自動更新
#1で述べた「サーバーデータをグローバルストアに丸ごと入れるアンチパターン」を避ける、最も現実的な方法がまさにこのツールです。サーバー状態がTanStack Queryに抜けると、グローバルストアに残るのは本物のクライアント状態だけです。そのクライアント状態を軽く扱うツールを、次回から見ていきます。
次回「React状態管理の深掘り #3 Zustandで扱う軽量なクライアント状態」では、最小限のコードでグローバルなクライアント状態を共有するZustandを扱います。