React状態管理の深掘り #2 TanStack Queryで扱うサーバー状態

読了 6分

#1では、状態をクライアント状態とサーバー状態に分けました。この記事はそのうちサーバー状態を扱います。投稿一覧、ユーザー情報、検索結果のように、サーバーに原本があり、画面は複製を見せているだけのデータです。

サーバー状態の標準ツールはTanStack Queryです。かつてReact Queryという名前で知られ、今はReact以外のフレームワークも支援するようになり、TanStack Queryに名前が変わりました。この記事のコードはv5基準です。

useEffectで書いていた方式の問題 #

まず、基礎講座 #10で学んだおなじみの方式を思い出してみます。

useEffect + useStateのデータ取得
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つで、すべてのキャッシュを管理します。

main.jsx
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で書き直すと、こうなります。

useQueryで書き直したPostList
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)」と印を付けます。古いデータは画面にそのまま見せつつ、適切なタイミング(ウィンドウに再びフォーカスが来たときなど)にバックグラウンドで取得し直します。

staleTimeの設定
useQuery({
  queryKey: ["posts"],
  queryFn: fetchPosts,
  staleTime: 60 * 1000, // 60秒間は取得し直さない
});

staleTimeの既定値は0なので、既定の設定ではデータを受け取った瞬間に古いとみなし、頻繁に確認し直します。あまり変わらないデータなら、staleTimeを伸ばして不要なリクエストを減らせます。

注記
staleTimeと混同しやすい値にgcTime(ガベージコレクション時間、既定5分)があります。staleTimeは「いつ取得し直すか」を、gcTimeは「画面で使われなくなったキャッシュをいつメモリから捨てるか」を決めます。v5で、かつてのcacheTimegcTimeに名前が変わりました。

useMutation — データを変える #

読みがuseQueryなら、書き(作成・更新・削除)はuseMutationです。新しい投稿を登録する例です。

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 + useStateTanStack Query
ローディング・エラー状態手で変数管理isPending / isErrorを提供
キャッシュなしqueryKey基準で自動
重複リクエストの統合なし自動
ウィンドウフォーカス時の更新自前で実装標準で提供
変更後の一覧更新手で状態を修正invalidateQueries1行

まとめ #

サーバー状態はTanStack Queryに任せれば、キャッシュと再取得、鮮度管理が付いてきます。

  • useQuery({ queryKey, queryFn }) — 読み、キャッシュとローディング・エラー状態が自動
  • queryKey — キャッシュを識別するキー、変数を入れてパラメータ別にキャッシュ
  • staleTime — データをどれくらい新鮮とみなすか
  • useMutation + invalidateQueries — 書きのあと関連キャッシュを無効化して自動更新

#1で述べた「サーバーデータをグローバルストアに丸ごと入れるアンチパターン」を避ける、最も現実的な方法がまさにこのツールです。サーバー状態がTanStack Queryに抜けると、グローバルストアに残るのは本物のクライアント状態だけです。そのクライアント状態を軽く扱うツールを、次回から見ていきます。

次回「React状態管理の深掘り #3 Zustandで扱う軽量なクライアント状態」では、最小限のコードでグローバルなクライアント状態を共有するZustandを扱います。

X