Reactのさまざまなデータフェッチ方法を比較して理解して正しく使用する - SWR・TanStack Query編

2023/11/22に公開

「Reactのさまざまなデータフェッチ方法を比較して理解して正しく使用する」シリーズの2記事目です。今回は「SWR・TanStack Queryを用いたデータフェッチ」について理解していきます。

  1. イントロ+useEffectを用いたデータフェッチ
  2. SWR・TanStack Queryを用いたデータフェッチ ← 👀この記事
  3. Pages Routerでのデータフェッチ+App Routerでのデータフェッチ+まとめ

Repository

以下は今シリーズで用いたリポジトリです。

🔽クライアントサイドフェッチの調査に用いたリポジトリ:React+Vite(useEffect, SWR・TanStack Query)
https://github.com/saku-1101/caching-swing-csr
🔽サーバーサイドフェッチの調査に用いたリポジトリ:Next.js Pages Router, App Router
https://github.com/saku-1101/caching-swing-pages
https://github.com/saku-1101/caching-swing

SWRを用いたクライアントサイドフェッチ

SWRを用いてデータのフェッチ・更新を行うときの挙動の確認から始めていきます。

まず、SWRのようなサードパーティ製のデータフェッチライブラリを使うことのメリットとしては次の点が挙げられます。

  • propsのバケツリレーを起こさずに、コンポーネント各々がオーナーシップを持ってデータを扱える点
  • 各コンポーネントでデータフェッチを行うようにしても無駄なリクエストが発生しない点
  • レスポンスのキャッシュが行える点
  • mutateを使用して直感的に更新後の状態をUIに反映できる点
  • データ取得中や更新中の状態管理をしやすいのでユーザに細かく正確なフィードバックを送ることができ、UXを高められる点

そのほかにもたくさんのメリットがSWRのドキュメントで紹介されています。

SWRを用いたデータフェッチの調査方法

それでは早速、SWRを用いてデータフェッチする処理を書いてみましょう。

https://github.com/saku-1101/caching-swing-csr/blob/d5e43f783b74ec29eb3a8410b7e84d45d6e9fdc5/src/prc-swr/index.tsx#L7-L18
前回のuseEffectを使ったデータフェッチに比べて、ここではデータ取得を行っておらず、各コンポーネントもpropsを持っていません。

その代わりに、データ取得のためのhooksをいくつか追加しました。
https://github.com/saku-1101/caching-swing-csr/tree/main/src/prc-swr/hooks
/src/prc-swr/hooks
/src/prc-swr/hooks
これらのhooksをそのデータを必要とする各コンポーネントから呼び出してもらうことで、データ取得の責務を各コンポーネントが持つことができ、コンポーネント同士がpropsで密に接合された状態になることを防ぎます。

以下はuseSWRを使用したデータフェッチのためのカスタムhooksの一例です。
https://github.com/saku-1101/caching-swing-csr/blob/d5e43f783b74ec29eb3a8410b7e84d45d6e9fdc5/src/prc-swr/hooks/useGithub.ts#L4-L12
useSWR()errorloading, validating(再検証中)などのデータ取得の際に起こる状態を返してくれるので、より細かで正確なフィードバックを行うことができます。

それでは、Personコンポーネントでユーザ名を更新してみましょう🏃🏻‍♀️
https://github.com/saku-1101/caching-swing-csr/blob/d5e43f783b74ec29eb3a8410b7e84d45d6e9fdc5/src/prc-swr/children/user.tsx#L5-L20
https://github.com/saku-1101/caching-swing-csr/blob/d5e43f783b74ec29eb3a8410b7e84d45d6e9fdc5/src/prc-swr/hooks/useGetUser.ts#L4-L24
DB更新処理までは前回同様、POSTリクエストを送信しているだけです。

SWRではデータ更新の際にmutateメソッドを使用することで同一のキーを持つリソースに対して再検証を発行 (データを期限切れとしてマークして再フェッチを発行) できます。
ここではmutateメソッドがuseGetUser内のuseSWRから発行されたものですので、/api/get/userをキーとして持つリソース、つまりuseGetUser内部で使用しているuseSWRに「そのデータ古いですよー」と伝えて再フェッチを促します。すると、validationがトリガーされ、最新のデータがフェッチされてUIに反映されます。

結果

先ほどのデータ更新時の再レンダリング範囲に注目してみます。
以下のようにuseGetUserを使用しているコンポーネントでのみ再レンダリングが発火していることが確認できるかと思います。
SWRを使うと限定的な範囲で再レンダリングができる
SWRを使うと限定的な範囲で再レンダリングができる

また、ほかにもSWRにはデータを最新に保つ仕組みがいくつか備わっています。その一部を見てみましょう。

Revalidate on Focus

windowにフォーカスが当たった場合に自動的に再検証が走り、最新のデータがフェッチされ、再レンダリングされます。
SWR: Revalidate on Focus
SWR: Revalidate on Focus

Revalidate on Interval

windowにフォーカスを当てずとも、ポーリング間隔を指定することで、一定の間隔でデータフェッチの問い合わせを行って再検証を走らせることができます。異なるデバイス間で定期的にデータ同期を行う際に便利です。

useGetUser.ts
export const useGetUser = () => {
  const url = "/api/get/user";
  const { mutate, data, error, isLoading, isValidating } = useSWR(
    `${import.meta.env.VITE_API_BASE_URL}${url}`,
    fetcher,
+    {
+       refreshInterval: 5000,
+    }
  );
  return {
    mutate,
    user: data,
    userError: error,
    userIsLoading: isLoading,
    userIsValidating: isValidating,
  };
};

SWR: Revalidate on Interval
SWR: Revalidate on Interval

リクエストの重複

SWRにはリクエストの重複を排除する仕組みが備わっています。

この例では、各コンポーネントにデータ取得の責務を結びつけるために、内部でuseSWRを用いたカスタムhooksをそれぞれの子コンポーネント内で呼び出していました。

これにより、ユーザ情報を必要とするPersonコンポーネントとHeaderコンポーネントそれぞれでuseGetUser hookをコールすることになるのですが、リクエストが2回起こることにならないのでしょうか?😶

useSWRでは同じキーを持ち、ほぼ同時期にレンダリングされるコンポーネントに関しては、リクエストは一つにまとめられます。
ここでは

  • /api/user/get @ Header, Personコンポーネント
  • /api/get/unstale/data @ Header, Contentコンポーネント
  • https://github.com @ Header, Contentコンポーネント

と、6回のAPIコールを実装していました。
SWRを使うと重複したリクエストは排除される
SWRを使うと重複したリクエストは排除される
しかし、実際は3回のリクエストしか発生していません。

また、データ更新を行なったときも、更新後にrevalidationしたキーの紐づくデータの再フェッチしか行いません。SWRでは1度取得したレスポンスはクライアントサイドキャッシュに保存され、次に同じリクエストを送る場合はリクエストを送らずにキャッシュからデータが返される仕様になっているからです。

SWR は、まずキャッシュからデータを返し(stale)、次にフェッチリクエストを送り(revalidate)、最後に最新のデータを持ってくるという戦略です。
https://swr.vercel.app/ja

したがって、上でユーザ名を更新した時に起こるトランザクションは新しいuserPOSTGET2回のみになり、githubやrandomNumberの再フェッチは行われません。
SWRを使うと再検証されるデータのみ再フェッチされ、あとはキャッシュから返される
SWRを使うと再検証されるデータのみ再フェッチされ、あとはキャッシュから返される

この重複排除の仕組みのおかげで、リクエスト回数によるパフォーマンスの問題を気にせずにアプリ内でバシバシSWRフックを再利用することができます💪🏻❤️‍🔥

TanStack Queryを用いたクライアントサイドフェッチ

TanStack QueryもSWRと同様クライアントサイドキャッシュを利用したデータフェッチが行えるライブラリです。
バンドルサイズはSWRの3倍ほどありますが、Query Hooksの戻り値の種類が多かったり、Query Hooksが持っているoptionの数が多かったりとSWRよりも高機能です。

そんなTanStack Queryを用いてデータのフェッチ・更新を行うときの挙動も確認していきます。

TanStack Queryを用いたデータフェッチの調査

まずは、初期設定です。
https://github.com/saku-1101/caching-swing-csr/blob/a9b407e62e9f47138fd4add7e9e007f3724f3ad7/src/prc-tanstack/index.tsx#L9-L27
TanStack Queryは内部的にuseContextuseEffectなどを使用しているため、TanStack Queryを使用するコンポーネントをまるっとQueryClientProviderでラップします。

QueryClientProvidernewしたQueryClientインスタンスと接続し、インスタンスを内部のコンポーネントに提供して使用できるようにしてくれます。
(ここでは一旦broadcastQueryClientの存在は無視してください)

TanStack QueryでもSWRと同様、カスタムhooksを用いてデータ取得を各々のコンポーネントで行うため、propsのバケツリレーを防ぐことができます。

以下のように、データフェッチをカスタムhooksに切り出します。
https://github.com/saku-1101/caching-swing-csr/blob/a9b407e62e9f47138fd4add7e9e007f3724f3ad7/src/prc-tanstack/hooks/useGithub.ts#L4-L18
こうすることでデータフェッチhooksが再利用可能になり、各コンポーネントでデータフェッチが行えるので、データ取得の責務をコンポーネントに委譲することができます。

Personコンポーネントでユーザ名を更新してみます。
https://github.com/saku-1101/caching-swing-csr/blob/main/src/prc-tanstack/hooks/useMutateUser.ts
https://github.com/saku-1101/caching-swing-csr/blob/a9b407e62e9f47138fd4add7e9e007f3724f3ad7/src/prc-tanstack/children/user.tsx#L7-L16
TanStack Queryでは更新処理専用のuseMutation hooksが存在し、そのhooksが更新・更新時の状態を管理します。

ここから少し細かい話に入ります。

useMutation hooksに注目してほしいのですが、これが存在することにより、TanStack Queryではmutationという処理を行っているときの状態が管理できます。つまり、mutateが使われたとき=データ更新処理が発火したときからisPendingという状態を受け取ることができます。

データ更新が正常に行われると、onSuccessでその状態を受け取り、queryClient.invalidateQueries({ queryKey: ["/api/get/user"] });にて/api/get/userをキーにもつリソースの再検証を発行します。

再検証を発行されたリソース(ここではTanStack QueryのuseGetUser)はデータの再フェッチを行い、そのときの状態はuseGetUserの内部で用いられているuseQueryからisFetchingとして受け取ることができます。

SWRとTanStack Queryでのmutateの比較

⭐️まとめると、TanStack QueryでuseMutationを用いたときのデータ更新処理は

  1. mutateでデータ更新をトリガーする
  2. データ更新がトリガーされたらisPendingを返す(Updating...表示)
  3. データ更新が完了したらonSuccessが状態を受け取り、キーを持つリソースの再検証を発行する(useGetUserにデータが古いことを伝えてリフェッチを促す)(Updating...非表示)
  4. useGetUserが再検証を開始するとともにisFetchingを返す(⏳loading...表示)
  5. useGetUser内のuseQueryqueryFnの処理でデータの再フェッチを行う(⏳loading...表示)
  6. queryFnの処理が完了する(⏳loading...非表示)
    TanStack Queryでのデータ更新時の状態管理
    TanStack Queryでのデータ更新時の状態管理

⭐️SWRを用いたときのデータ更新処理は

  1. fetch関数が呼び出されてデータ更新が行われる
  2. データ更新が完了する
  3. mutate(key)でキーを持つリソースの再検証を発行する(useGetUserにデータが古いことを伝えてリフェッチを促す)
  4. useGetUserが再検証を開始するとともにisValidatingを返す(⏳loading...表示)
  5. useGetUser内のuseSWRの第二引数の処理でデータの再フェッチを行う(⏳loading...表示)
  6. 5の処理が完了する(⏳loading...非表示)
    SWRでのデータ更新時の状態管理
    SWRでのデータ更新時の状態管理

となり、DB update処理中(API内部処理実行中)の状態を、TanStack Queryはwatchできるのに対し、SWRではその機能は提供されていないということになります。
(訂正)SWRメンテナーの方から、「SWR でも useSWRMutation を使って mutation を行うと isMutating として更新中の状態を取れるのでよかったら使ってみてください!」
というメッセージがありました。@koba04さん、ありがとうございます!
https://swr.vercel.app/docs/mutation#useswrmutation-api

結果

少し脇道に逸れましたが、上記の動画より、TanStack QueryもSWRのようにuseGetUserを使用しているコンポーネントでのみ再レンダリングが発火していることがわかります。TanStack Queryもキーによってデータの取得・更新処理を行うか否かを管理しているからです。

また、TanStack Queryにもデータを最新に保つ仕組みが備わっています。Window Focus RefetchingについてSWRと比較して見てみましょう。

Window Focus Refetching

v4まではwindowにフォーカスが当たった場合に自動的に再検証が走り、最新のデータに書きかわる、SWR同等の仕様でした。

しかし、こちらのPRによりfocusイベントで再検証が走ることのデメリットが議論された結果、v5からはfocusイベントではなくvisibilitychangeによって自動的再検証が走るような仕様になっているようです。

現状focusで再検証が走るSWR
現状focusで再検証が走るSWR - devtoolから戻ってきたときや、windowがクリックされたとき、別ディスプレイに行って戻ってきたときにも再検証が走る

visibilitychangeで再検証が走るTanStack Query
visibilitychangeで再検証が走るTanStack Query - 単にfocusでは再検証は走らない

focusで再検証が走ることはSWRでも議論されており、PRも出ているので、将来的にはmergeされてTanStack Queryの仕様に近づくのだと思います。🏗️

リクエストの重複

こちらもSWR同様、リクエストをキーで管理しているので重複が排除されます。
TanStack Queryを使うと重複したリクエストは排除される
TanStack Queryを使うと重複したリクエストは排除される


今回の記事ではSWRとTanStack Queryを使用したデータフェッチについて、理解を深めていきました。

SWRやTanStack Queryのサードパティー製のフェッチライブラリを使うことで、重複を排除したデータフェッチ、レスポンスのクライアントサイドキャッシュが行えるだけでなく、データ取得・更新時の状態を管理してくれるなどの自前で実装すると少し手の込む内部的なロジックを享受できます。

Reactのクライアントサイドでデータをフェッチする手段として最有力の候補として持っておきたいですね🌟

次回は、Reactのサーバーサイドでデータをフェッチする方法の代表としてNext.js Pages RouterでSSRのデータフェッチ・Next.js App RouterでReact Server Componentsを使用してのデータフェッチを理解していく記事です。

複雑なサーバーサイドでのデータフェッチについて、一緒に考えながら読んでいただけると幸いです✉️

(余談)TanStack QueryのbroadcastQueryClientという実験的な機能

TanStack Queryがwindowにフォーカスが当たった場合ではなくvisibilitychangeによってデータの再検証を行う方向になったお話を先ほどしました。

以前TanStack Queryを使用したときは、windowフォーカスで再検証が行われていたため、今回の調査の時にwindowを二つ開いて一つのwindowでデータを更新した時、もう一つのwindowに戻ってデータが更新されないことに(?)となり、Q&Aを投げてみました。

https://github.com/TanStack/query/discussions/6364

結果、私の確認不足ということで、v5から上記の挙動に変わっていました。
しかし、今はbroadcastQueryClientでアプリレベルでconnectionを張って変更を検知できるようにしている機能を開発してるよという回答をいただき、詳細な仕組みは理解できていませんが、それも試してみました。(実はこれもexperimentalとしてlatestのdocumentには明記されている)

page.tsx
"use client";
+import { broadcastQueryClient } from "@tanstack/query-broadcast-client-experimental";
import { QueryClient, QueryClientProvider } from "@tanstack/TanStack Query";
import BackButton from "../_component/back-button";
import LinkButton from "../_component/link-button";
import Content from "./children/content";
import Header from "./children/header";
import { Person } from "./children/user";

export default function TanstackPage() {
	const queryClient = new QueryClient();
+	broadcastQueryClient({
+		queryClient,
+		broadcastChannel: "tanstack-app",
+	});
	return (
		<QueryClientProvider client={queryClient}>
			<div>
				<Header />
				<Content />
				<Person />
				<BackButton />
				<LinkButton link="/prc-swr" label="swr" />
				<LinkButton link="/prc-fetch" label="fetch" />
				<LinkButton link="/legacy-fetch" label="legacy" />
			</div>
		</QueryClientProvider>
	);
}

片方のwindowで更新をかけると、同期的にもう片方のwindowでも値が変更される
片方のwindowで更新をかけると、同期的にもう片方のwindowでも値が変更される

動作環境:
"@tanstack/query-broadcast-client-experimental": "5.8.3"
"@tanstack/TanStack Query": "5.8.3"

サイボウズ フロントエンド

Discussion