♾️

【TanStack Query】persistersプラグインでキャッシュを永続化させる

2025/02/28に公開

はじめに

この記事では、TanStack Query v5における、persistersプラグインについて述べています。

persistersによって、キャッシュを様々なストレージレイヤーに置くことができ、キャッシュの永続化を図ることができます。

筆者が実装の中でpersistersを使用したユースケースなども交えて解説してみますので、お役に立てれば幸いです。

普通のキャッシュ

persisterによるキャッシュの話をする前に、これを使わない「普通」のキャッシュから見ていきます。

ユースケース

例として、ボタンを押すと、ヘルスチェックAPIを叩くというユースケースを用います。

Image from Gyazo

コードは以下です。

コード

内部でuseSuspenseQueryを使っているカスタムフックです。
このフックを関数コンポーネントで呼び出すことで、データをフェッチ&キャッシュします。

hook.ts
export function useHealthCheckMesageQuery() {
	const { data } = useSuspenseQuery(heatlhCheckMessageOptions);
	return data;
}

useSuspenseQueryに渡すオプションです。

options.ts
export const heatlhCheckMessageOptions = queryOptions({
	queryKey: ["health"],
	queryFn: () => getHealthCheck(),
	select: getHealthCheckSelector,
});

APIを実際に叩いている関数で、queryFnにあたります。
挙動を見やすくするために、2秒間スリープさせています。

api.ts
export async function getHealthCheck(): Promise<HealthCheckResponse> {
	await new Promise((resolve) => {
		setTimeout(resolve, 2000);
	});
	const response = await fetch("http://localhost:8080/health");
	if (!response.ok) {
		throw new Error("Error");
	}
	return response.json().then((data) => camelcaseKeys(data, { deep: true }));
}

queryFnからフェッチした値を整形してuseSuspenseQueryの最終的な返り値を変えられるselectorオプションに割り当てている関数です。

selector.ts
export function getHealthCheckSelector(
	response: HealthCheckResponse,
): HealthCheckMessage {
	return { message: response.data.healthCheck };
}

型です。

types.ts
export type HealthCheckResponse = {
	data: {
		healthCheck: string;
	};
	status: string;
};

export type HealthCheckMessage = {
	message: string;
};

参考までにですが、QueryClientProviderコンポーネントで全体をラップしています。
関係ないところはコメントアウトしています。

AppProvider.tsx
type AppProviderProps = {
	children: ReactNode;
};

function AppProvider({ children }: AppProviderProps) {
	return (
		<>
			<QueryClientProvider client={queryClient}>
				{/* <ErrorBoundary fallback={<p>error</p>}>
					<Suspense fallback={<LoadingSpinner />}>
						<AuthProvider>
							<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> */}
								{children}
							{/* </ThemeProvider>
						</AuthProvider>
					</Suspense>
				</ErrorBoundary>
				<ReactQueryDevtools initialIsOpen={false} /> */}
			</QueryClientProvider>
		</>
	);
}

export default AppProvider;

クエリクライアントは別ファイルに切り出しています。

queryClient.ts
export const queryClient = newQueryClient();

function newQueryClient(): QueryClient {
	const queryClient = new QueryClient();
	return queryClient;
}

もう一度、ボタンを叩いてみます。
Image from Gyazo

すると、初回とは違い即座に表示されました。
これは、2回目のリクエストでは、キャッシュが利用されたからです。

キャッシュ状態

先の挙動を見る前に、キャッシュの状態についてみておきます。
以下の3つです。

1. Fresh

データが最初にフェッチされた直後の状態です。「新鮮=最新」ということですね。
キャッシュが最新、つまりFreshなステータスとTanstack Queryが認識した場合、2回目のリクエスト以降、そのキャッシュをレスポンスとして即座に返します。

ここで重要なのは、再フェッチを行わないことです。
データが最新なのだから、再フェッチする必要はないということでしょう。

ソースコードには以下のような記述があります。

/**
 * The time in milliseconds after data is considered stale.
 * If the data is fresh it will be returned from the cache.
 */
staleTime?: StaleTime<TQueryFnData, TError, TData, TQueryKey>;

2. Stale

キャッシュされたデータは、Freshを経過してStale、つまり「古い」状態となります。

この状態のデータは、まだキャッシュとして利用されます。

しかし、Freshな時と違い、2回目以降のリクエストでは、キャッシュされたデータを即座に返しつつ、バックグラウンドで新たにデータをフェッチします。

そして、バックグラウンドで新たなデータが取得できるとキャッシュを更新し、コンポーネントを再レンダリングして新しいデータを表示し直します。

これは、Stale-While-Revalidateと言われるキャッシュパターンです。
https://zenn.dev/devcamp/articles/e9877f79230ef2#stale-while-revalidateの概要

また、Freshな状態からStaleな状態に移行するまでの時間を指定できるstaleTimeというオプションがあります。
デフォルトでは、これが「0」に設定されています。つまり、即座にキャッシュがStaleな状態に移行するということです。

Staleな状態のキャッシュが存在する時、以下のタイミングで再フェッチが行われます。

  • 新たなクエリインスタンスがマウントされた時(useQueryを使っているコンポーネントがマウントされた時)
  • ウィンドウがフォーカスされたとき(タブを切り替えて戻ってきたとき)
  • ネットワークが再接続されたとき

上記の3つのタイミングも、オプションによって制御することができます。
https://tanstack.com/query/latest/docs/framework/react/reference/useQuery

3. Inactive

コンポーネントから利用されていないキャッシュのデータは、inactive(非アクティブ)な状態となります。
useQueryを使用するコンポーネントがアンマウントされるとき、キャッシュのデータは使われていないので、inactiveな状態に移行すると言えます。

そして、inactiveなキャッシュは、一定時間が経つとGC(ガーベージコレクション)され、削除されます

   /**
     * The time in milliseconds that unused/inactive cache data remains in memory.
     * When a query's cache becomes unused or inactive, that cache data will be garbage collected after this duration.
     * When different garbage collection times are specified, the longest one will be used.
     * Setting it to `Infinity` will disable garbage collection.
     */
    gcTime?: number;  

デフォルトでは、inactiveなキャッシュがGCされるまでの時間を指定するオプションであるgcTimeが5分に設定されているようです。
https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults

キャッシュの遷移をまとめると以下のようになります。(デフォルトの場合)

挙動

それでは、キャッシュについて少しわかったところで、先にあげたヘルスチェックボタンのユースケースがどんな挙動をしていたのかをみていきましょう。

キャッシュの状態遷移も併せて記載してみました。

  1. ボタンを押す
  2. ヘルスチェックAPIを叩くコンポーネントがマウントされる(ダイアログ)
  3. useSuspenseQuery()が走り、APIからのレスポンスを待ち受ける
  4. レスポンスデータを描画 & データをキャッシュ
    • キャッシュがFreshから即座にStaleに
  5. ダイアログを閉じると、コンポーネントがアンマウントする
    • キャッシュが使われなくなったのでInactiveに
  6. もう1度ボタンを押す
  7. コンポーネントがマウントされる(ダイアログ)
  8. useSuspenseQuery()が走り、キャッシュ(Inactiveだった)されていたデータを返す
    • バックグラウンドでは再フェッチしている
    • キャッシュがFreshから即座にStaleに

普通のキャッシュはこのような感じです。

普通のキャッシュにおいて、データがキャッシュされる場所は、メモリです。
つまり、ページをリフレッシュすればメモリはクリアされるため、キャッシュも破棄されます。

大半のユースケースではそれで構わないと思うのですが、

「リロードするごとに再フェッチされると困る...」

といったケースもあるでしょう。
筆者はそのようなユースケースに遭遇しました。

そこで、この問題を解決する、「persisterによるキャッシュ」について紹介します。

persistersによるキャッシュ

persistersを使うことによって、TanStack QueryのキャッシュをローカルストレージやIndexed DBに永続化することが可能です。

https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient

解決したかったユースケース

課題だったのは、認証状態の管理でした。
認証の話については、若干脱線する話ですので読み飛ばしてもらっても大丈夫です。

認証状態の管理について

バックエンドAPIは、JWTによるトークンベース + Redisに、紐づくJTIがあるかないかのセッション管理を用いた認証方式を取っています。

フロント側で認証を管理するため、「ログインしている」状態を以下のように決めました。

  • users/meにリクエストを送り、成功レスポンスが返ってくるか

認証状態をグローバルに管理するために、Context APIを用いています。

AppProvider.tsx

<QueryClientProvider client={queryClient}>
    <ErrorBoundary fallback={<p>error</p>}>
        <Suspense fallback={<LoadingSpinner />}>
            <AuthProvider>   // 認証をグローバルに管理するためのプロバイダー
                <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
                    {children}
                </ThemeProvider>
            </AuthProvider>
        </Suspense>
    </ErrorBoundary>
    <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

プロバイダーコンポーネント

AuthProvider.tsx
function AuthProvider({ children }: { children: ReactNode }) {
	const auth = useAuthenticate();
	return (
		<>
			<AuthContext.Provider value={auth}>{children}</AuthContext.Provider>
		</>
	);
}

export default AuthProvider;

Authコンテキスト

context.ts

const initialState: AuthProviderState = {
	name: "",
	id: "",
	isAuthenticated: false,
};

export const AuthContext = createContext<AuthProviderState>(initialState);

認証状態をコンテキストで管理するためのカスタムフック

hooks.ts
export function useAuthenticate(): AuthProviderState {
	const auth = useContext(AuthContext);
	const { id, name } = useGetCurrentUserQuery();  // ここでusers/meをフェッチ
	if (id === "") {
		return {
			...auth,
			isAuthenticated: false,
		};
	}
	return {
		...auth,
		name: name,
		id: id,
		isAuthenticated: true,
	};
}

認証状態をコンテキストで管理するAuthProviderコンポーネントは、常にマウント状態です。

そして、その中で利用しているカスタムフックuseGetCurrentUserQuery()では、users/meからデータをフェッチします。

現状、userGetCurrentUserQuery()は課題を孕んでいます。

useGetCurrentUserQueryのコード

ヘルスチェックと特に変わりませんが、一応コードを記載しておきます。

api.ts
export async function getCurrentUser(): Promise<GetCurrentUserResponseData> {
	await new Promise((resolve) => {
		setTimeout(resolve, 2000);
	});
	const jwt = getCookies("jwt"); // CookieからJWTを抽出
	const response = await fetch("http://localhost:8080/users/me", {
		method: "GET",
		headers: {
			Authorization: `Bearer ${jwt}`,
		},
	});
	if (response.status === 401) {
		return {
			data: {
				id: "",
				name: "",
			},
			status: 401,
		};
	}
	return response.json().then((data) => camelcaseKeys(data, { deep: true }));
}
options.ts
export const getCurrentUserOptions = queryOptions({
	queryFn: getCurrentUser,
	queryKey: ["user","current"],
	select: getCurrentUserSelector,
	staleTime: 1000 * 60 * 60 * 24,  // 24時間はキャッシュFreshに保つ
});
selector.ts
import type { CurrentUserInfo, GetCurrentUserResponseData } from "./types";

export function getCurrentUserSelector(
	data: GetCurrentUserResponseData,
): CurrentUserInfo {
	const result: CurrentUserInfo = {
		id: data.data.id,
		name: data.data.name,
	};
	return result;
}
export type GetCurrentUserResponseData = {
	data: {
		id: string;
		name: string;
	};
	status: number;
};

export type CurrentUserInfo = {
	id: string;
	name: string;
};

というのも、リロードするごとにフェッチが走るのです。
Image from Gyazo

staleTimeを24時間にしているため、24時間はずっとキャッシュがFreshであり、再フェッチは行われないはず....。

と考えていましたが、そういえば、キャッシュはメモリに保存されているのでした。

となると、キャッシュはリロードとともに破棄されるので、当然こういったようになってしまうのですね。

リロードするごとにフェッチが行われ、画面がいちいちローディングするというのは、ユーザビリティとしてあまり好ましくありません。

そこでpersistersを使用し、キャッシュを永続化させ、リロードしても再フェッチしないようにしてみます。

persistersを使って解決

npmを使用している場合は、以下を打って@tanstack/query-sync-storage-persisterパッケージをインストールします。

npm install @tanstack/query-sync-storage-persister @tanstack/react-query-persist-client

そして、切り出していたクエリ初期化ファイルを以下のように書き換えました。

queryClient.ts
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { QueryClient } from "@tanstack/react-query";
import { persistQueryClient } from "@tanstack/react-query-persist-client";

export const queryClient = newQueryClient();

function newQueryClient(): QueryClient {
	const queryClient = new QueryClient({
		defaultOptions: {
			queries: {
				gcTime: 1000 * 60 * 60 * 24, // 24 hours
			},
		},
	});

	persistQueryClient({
		queryClient,
		persister: createSyncStoragePersister({ storage: window.localStorage }),
		dehydrateOptions: {
			shouldDehydrateQuery: (query) => {
				const keys = query.queryKey.map((key) => {
					return String(key);
				});
				const allowKeys = ["user","current"];
				return (
					keys.length === allowKeys.length &&
					keys.every((key) => allowKeys.includes(key))
				);
			},
		},
	});

	return queryClient;
}

persistQueryClient()関数

この関数をコンポーネント外で呼び出します。

persistQueryClient({
  queryClient,
  persister,
  maxAge = 1000 * 60 * 60 * 24, // 24 hours
  buster = '',
  hydrateOptions = undefined,
  dehydrateOptions = undefined,
})

https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#persistqueryclient

persisterオプション

persistQueryClient()関数のpersisterオプションには、createSyncStoragePersister()関数の返り値を割り当てています。

今回は、ローカルストレージにキャッシュを保存するようにしています。

persister: createSyncStoragePersister({ storage: window.localStorage })

dehydrateOptionsオプション

クエリから得たデータをキャッシュし、永続化するかどうかを指定できるオプションです。

dehydrateOptions: {
    shouldDehydrateQuery: (query) => {
        const keys = query.queryKey.map((key) => {
            return String(key);
        });
        const allowKeys = userKeys.current();
        return (
            keys.length === allowKeys.length &&
            keys.every((key) => allowKeys.includes(key))
        );
    },
},

このshouldDehydrateQuery関数は各クエリごとに呼び出され、trueを返した場合にのみ永続化されるキャッシュとみなされます。つまり、キャッシュはローカルストレージに保存されるということです。

今回は、users/meからフェッチしたデータのみ永続化できればいいので、queryqueryKey["user","current"]の場合にのみキャッシュを永続化するというふうにしています。
これで、ヘルスチェックに関しては「普通に」、メモリにてキャッシュが保持されるようになります。

https://tanstack.com/query/latest/docs/framework/react/reference/hydration

このローカルストレージに残り続ける時間(maxAgeオプション)はデフォルトで24時間のようです。
GCされる時間がこれより短いと、期待しているよりもキャッシュが早く破棄される可能性があります。そのため、以下のようにデフォルトでgcTimeは24時間に設定しておくべきだと公式docsに記載がありました。

const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            gcTime: 1000 * 60 * 60 * 24, // 24 hours
        },
    },
});

挙動

persistersを使い、どのような挙動になっているのかを整理してみます。

まずは以下に実際の画面を示します。
Image from Gyazo

2回目のリロードからローディングしていないのがわかります。
これは、2回目からはローカルストレージに保存されているキャッシュを利用しているからです。

ブラウザのローカルストレージには、REACT_QUERY_OFFLINE_CACHEという名前で保存されていることがわかります。

  1. ページを訪れる
  2. <AuthProvider>コンポーネントがマウントされ、内部でusers/meをフェッチする
  3. データがキャッシュデータとして、ローカルストレージに保存される
    • キャッシュは24時間Freshな状態(staleTime : 1000 * 60 * 60 * 24
  4. リロードする
  5. <AuthProvider>コンポーネントが再度マウントされる
  6. ローカルストレージ内のデータからメモリ内のキャッシュデータに復元される
  7. そのキャッシュデータを返すことで即座にページが表示される
    • Freshなキャッシュだと再フェッチを行わない

persistersを使用すると、以下のようにローカルストレージのデータとキャッシュがリアルタイムに同期します。

その他

persistQueryClient()関数を使った方法では、コンポーネントのライフサイクル外でデータを管理することで課題を解決し期待する挙動とすることができました。

しかし、欠点もあります。

コンポーネントのライフサイクル外にあるデータをSubscribe(利用)すると、それを解除する手段がないのです。そうなると、コンポーネントがアンマウントされてもリソースが解放されないまま残り続け、メモリリークが起きる可能性があります。

https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#usage-with-react

そこで、提供されている<PersistQueryClientProvider>コンポーネントを使用すれば、コンポーネントのライフサイクルに従ってSubscribe/UnSubscribeが行われるようになります。

<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister }}
>
 <App />
</PersistQueryClientProvider>,

ですが、今回は、リロードされても再フェッチされないようにしたい!という要件だったので、これは使いませんでした。

まとめ

今回は、persistersプラグインを使用したキャッシュの永続化というテーマでした。

先述した通り、認証状態の管理に悩んでいた際、リサーチしているとたまたまStack Overflowでの議論を見つけて、TanStack Queryにこの機能があることを知り、日本語リソースが見当たらなかったので書いてみました。

https://stackoverflow.com/questions/78253757/want-to-fetch-data-when-page-load-and-store-it-in-cache-and-when-i-reload-the-pa

Railsにおけるdeviseなどの認証機構でcurent_userメソッドを呼び出してサインインを判定するケースがあるように、今回のような、バックエンドAPIに対してusers/meとリクエストを送ってサインインを判定する場合には、persistersを使うのもありなのではないでしょうか。

Discussion