【TanStack Query】persistersプラグインでキャッシュを永続化させる
はじめに
この記事では、TanStack Query v5における、persistersプラグインについて述べています。
persistersによって、キャッシュを様々なストレージレイヤーに置くことができ、キャッシュの永続化を図ることができます。
筆者が実装の中でpersistersを使用したユースケースなども交えて解説してみますので、お役に立てれば幸いです。
普通のキャッシュ
persisterによるキャッシュの話をする前に、これを使わない「普通」のキャッシュから見ていきます。
ユースケース
例として、ボタンを押すと、ヘルスチェックAPIを叩くというユースケースを用います。
コードは以下です。
コード
内部でuseSuspenseQuery
を使っているカスタムフックです。
このフックを関数コンポーネントで呼び出すことで、データをフェッチ&キャッシュします。
export function useHealthCheckMesageQuery() {
const { data } = useSuspenseQuery(heatlhCheckMessageOptions);
return data;
}
useSuspenseQuery
に渡すオプションです。
export const heatlhCheckMessageOptions = queryOptions({
queryKey: ["health"],
queryFn: () => getHealthCheck(),
select: getHealthCheckSelector,
});
APIを実際に叩いている関数で、queryFn
にあたります。
挙動を見やすくするために、2秒間スリープさせています。
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オプションに割り当てている関数です。
export function getHealthCheckSelector(
response: HealthCheckResponse,
): HealthCheckMessage {
return { message: response.data.healthCheck };
}
型です。
export type HealthCheckResponse = {
data: {
healthCheck: string;
};
status: string;
};
export type HealthCheckMessage = {
message: string;
};
参考までにですが、QueryClientProvider
コンポーネントで全体をラップしています。
関係ないところはコメントアウトしています。
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;
クエリクライアントは別ファイルに切り出しています。
export const queryClient = newQueryClient();
function newQueryClient(): QueryClient {
const queryClient = new QueryClient();
return queryClient;
}
すると、初回とは違い即座に表示されました。
これは、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と言われるキャッシュパターンです。
また、Freshな状態からStaleな状態に移行するまでの時間を指定できるstaleTime
というオプションがあります。
デフォルトでは、これが「0」に設定されています。つまり、即座にキャッシュがStaleな状態に移行するということです。
Staleな状態のキャッシュが存在する時、以下のタイミングで再フェッチが行われます。
- 新たなクエリインスタンスがマウントされた時(
useQuery
を使っているコンポーネントがマウントされた時) - ウィンドウがフォーカスされたとき(タブを切り替えて戻ってきたとき)
- ネットワークが再接続されたとき
上記の3つのタイミングも、オプションによって制御することができます。
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分に設定されているようです。
キャッシュの遷移をまとめると以下のようになります。(デフォルトの場合)
挙動
それでは、キャッシュについて少しわかったところで、先にあげたヘルスチェックボタンのユースケースがどんな挙動をしていたのかをみていきましょう。
キャッシュの状態遷移も併せて記載してみました。
- ボタンを押す
- ヘルスチェックAPIを叩くコンポーネントがマウントされる(ダイアログ)
-
useSuspenseQuery()
が走り、APIからのレスポンスを待ち受ける - レスポンスデータを描画 & データをキャッシュ
- キャッシュがFreshから即座にStaleに
- ダイアログを閉じると、コンポーネントがアンマウントする
- キャッシュが使われなくなったのでInactiveに
- もう1度ボタンを押す
- コンポーネントがマウントされる(ダイアログ)
-
useSuspenseQuery()
が走り、キャッシュ(Inactiveだった)されていたデータを返す- バックグラウンドでは再フェッチしている
- キャッシュがFreshから即座にStaleに
普通のキャッシュはこのような感じです。
普通のキャッシュにおいて、データがキャッシュされる場所は、メモリです。
つまり、ページをリフレッシュすればメモリはクリアされるため、キャッシュも破棄されます。
大半のユースケースではそれで構わないと思うのですが、
「リロードするごとに再フェッチされると困る...」
といったケースもあるでしょう。
筆者はそのようなユースケースに遭遇しました。
そこで、この問題を解決する、「persisterによるキャッシュ」について紹介します。
persistersによるキャッシュ
persistersを使うことによって、TanStack QueryのキャッシュをローカルストレージやIndexed DBに永続化することが可能です。
解決したかったユースケース
課題だったのは、認証状態の管理でした。
認証の話については、若干脱線する話ですので読み飛ばしてもらっても大丈夫です。
認証状態の管理について
バックエンドAPIは、JWTによるトークンベース + Redisに、紐づくJTIがあるかないかのセッション管理を用いた認証方式を取っています。
フロント側で認証を管理するため、「ログインしている」状態を以下のように決めました。
-
users/me
にリクエストを送り、成功レスポンスが返ってくるか
認証状態をグローバルに管理するために、Context APIを用いています。
<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>
プロバイダーコンポーネント
function AuthProvider({ children }: { children: ReactNode }) {
const auth = useAuthenticate();
return (
<>
<AuthContext.Provider value={auth}>{children}</AuthContext.Provider>
</>
);
}
export default AuthProvider;
Authコンテキスト
const initialState: AuthProviderState = {
name: "",
id: "",
isAuthenticated: false,
};
export const AuthContext = createContext<AuthProviderState>(initialState);
認証状態をコンテキストで管理するためのカスタムフック
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のコード
ヘルスチェックと特に変わりませんが、一応コードを記載しておきます。
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 }));
}
export const getCurrentUserOptions = queryOptions({
queryFn: getCurrentUser,
queryKey: ["user","current"],
select: getCurrentUserSelector,
staleTime: 1000 * 60 * 60 * 24, // 24時間はキャッシュFreshに保つ
});
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;
};
staleTime
を24時間にしているため、24時間はずっとキャッシュがFreshであり、再フェッチは行われないはず....。
と考えていましたが、そういえば、キャッシュはメモリに保存されているのでした。
となると、キャッシュはリロードとともに破棄されるので、当然こういったようになってしまうのですね。
リロードするごとにフェッチが行われ、画面がいちいちローディングするというのは、ユーザビリティとしてあまり好ましくありません。
そこでpersistersを使用し、キャッシュを永続化させ、リロードしても再フェッチしないようにしてみます。
persistersを使って解決
npmを使用している場合は、以下を打って@tanstack/query-sync-storage-persister
パッケージをインストールします。
npm install @tanstack/query-sync-storage-persister @tanstack/react-query-persist-client
そして、切り出していたクエリ初期化ファイルを以下のように書き換えました。
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,
})
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
からフェッチしたデータのみ永続化できればいいので、query
のqueryKey
が["user","current"]
の場合にのみキャッシュを永続化するというふうにしています。
これで、ヘルスチェックに関しては「普通に」、メモリにてキャッシュが保持されるようになります。
このローカルストレージに残り続ける時間(maxAge
オプション)はデフォルトで24時間のようです。
GCされる時間がこれより短いと、期待しているよりもキャッシュが早く破棄される可能性があります。そのため、以下のようにデフォルトでgcTime
は24時間に設定しておくべきだと公式docsに記載がありました。
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24 hours
},
},
});
挙動
persistersを使い、どのような挙動になっているのかを整理してみます。
2回目のリロードからローディングしていないのがわかります。
これは、2回目からはローカルストレージに保存されているキャッシュを利用しているからです。
ブラウザのローカルストレージには、REACT_QUERY_OFFLINE_CACHE
という名前で保存されていることがわかります。
- ページを訪れる
-
<AuthProvider>
コンポーネントがマウントされ、内部でusers/me
をフェッチする - データがキャッシュデータとして、ローカルストレージに保存される
- キャッシュは24時間Freshな状態(
staleTime : 1000 * 60 * 60 * 24
)
- キャッシュは24時間Freshな状態(
- リロードする
-
<AuthProvider>
コンポーネントが再度マウントされる - ローカルストレージ内のデータからメモリ内のキャッシュデータに復元される
- そのキャッシュデータを返すことで即座にページが表示される
- Freshなキャッシュだと再フェッチを行わない
persistersを使用すると、以下のようにローカルストレージのデータとキャッシュがリアルタイムに同期します。
その他
persistQueryClient()
関数を使った方法では、コンポーネントのライフサイクル外でデータを管理することで課題を解決し期待する挙動とすることができました。
しかし、欠点もあります。
コンポーネントのライフサイクル外にあるデータをSubscribe(利用)すると、それを解除する手段がないのです。そうなると、コンポーネントがアンマウントされてもリソースが解放されないまま残り続け、メモリリークが起きる可能性があります。
そこで、提供されている<PersistQueryClientProvider>
コンポーネントを使用すれば、コンポーネントのライフサイクルに従ってSubscribe/UnSubscribeが行われるようになります。
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister }}
>
<App />
</PersistQueryClientProvider>,
ですが、今回は、リロードされても再フェッチされないようにしたい!という要件だったので、これは使いませんでした。
まとめ
今回は、persistersプラグインを使用したキャッシュの永続化というテーマでした。
先述した通り、認証状態の管理に悩んでいた際、リサーチしているとたまたまStack Overflowでの議論を見つけて、TanStack Queryにこの機能があることを知り、日本語リソースが見当たらなかったので書いてみました。
Railsにおけるdeviseなどの認証機構でcurent_user
メソッドを呼び出してサインインを判定するケースがあるように、今回のような、バックエンドAPIに対してusers/me
とリクエストを送ってサインインを判定する場合には、persistersを使うのもありなのではないでしょうか。
Discussion