AspidaとTanStack Query(React Query)を使ったお手軽キャッシュ最適化
今回の主役
使用するライブラリ
-
aspida-react-query(カスタムフックの参考にしました)
<!-- openapiの定義ファイルがないとき -->
pnpm i axios @aspida/axios @tanstack/react-query
<!-- openapiの定義ファイルがあるとき -->
<!-- 本記事ではopenapi2aspidaの使い方については説明しませんが、とても便利です! -->
pnpm i axios @aspida/axios @tanstack/react-query openapi2aspida
TL;DR
- Aspidaで生成されたコードを使ってReact Queryの
QueryKey
を自動で設定するカスタムフックを作ろう - デフォルトの
staleTime
を長く設定してキャッシュの恩恵を最大限受けよう - React Queryの
Query invalidation
機能を使ってキャッシュの再取得をするカスタムフックを作ろう
React Queryとは?
TanStack Query (FKA React Query) is often described as the missing data-fetching library for web applications, but in more technical terms, it makes fetching, caching, synchronizing and updating server state in your web applications a breeze.
ざっくり言うとデータフェッチやキャッシュ周りをいい感じに管理してくれる+React Hooksを使って簡潔に記載できるライブラリです。
公式ドキュメントのOverviewから引用してコメントで解説を追記します。
もう概要は知っているよという方は読み飛ばしてください。
公式ドキュメントに解説を追記したコードサンプル
import {
QueryClient,
QueryClientProvider,
useQuery,
} from '@tanstack/react-query'
// queryClientの内部でcache等の状態を持つ
const queryClient = new QueryClient()
export default function App() {
// QueryClientProviderでラップした内部のコンポーネントから状態へアクセス可能
return (
<QueryClientProvider client={queryClient}>
<Example />
</QueryClientProvider>
)
}
function Example() {
// isLoading, error, dataなど共通のインターフェースでレンダリングに必要な情報を取得できて便利
const { isLoading, error, data } = useQuery({
// 今回の記事のキモ!次項で解説しています
queryKey: ['repoData'],
queryFn: () =>
fetch('https://api.github.com/repos/tannerlinsley/react-query').then(
(res) => res.json(),
),
})
if (isLoading) return 'Loading...'
if (error) return 'An error has occurred: ' + error.message
return (
<div>
<h1>{data.name}</h1>
<p>{data.description}</p>
<strong>👀 {data.subscribers_count}</strong>{' '}
<strong>✨ {data.stargazers_count}</strong>{' '}
<strong>🍴 {data.forks_count}</strong>
</div>
)
}
Query Keysによるキャッシュ制御
useQuery
のキャッシュの管理はqueryKey
引数に基づいて制御されます。
同じqueryKey
を渡して呼び出した際にfreshなキャッシュがあるとそれを返し、キャッシュが存在しなかったりキャッシュがstale(freshでない)な場合はqueryFn
をコールして新たにデータをキャッシュしてくれます。
Query Keysは、以下のような特徴を持っています。 (公式ドキュメントのQuery Keysから抜粋)
- 配列である
- 文字列やオブジェクトなどを含むことができる
- オブジェクト内の並び順は
queryKey
の同値判定に関係しない - 配列の要素の並び順は
queryKey
の同値判定に関係する
コードで解説すると、以下の通りです。
// 全て同じ`queryKey`として処理される
useQuery({ queryKey: ['todos', { status, page }], ... })
useQuery({ queryKey: ['todos', { page, status }], ...})
useQuery({ queryKey: ['todos', { page, status, other: undefined }], ... })
// 全て異なる`queryKey`として処理される
useQuery({ queryKey: ['todos', status, page], ... })
useQuery({ queryKey: ['todos', page, status], ...})
useQuery({ queryKey: ['todos', undefined, page, status], ...})
Query Invalidation機能
各useQuery
を呼ぶ際に、オプションにstaleTime
を渡してキャッシュの時間を制御することができます。
ただし、実際のアプリケーションでは特定のタイミング(ユーザーアクションの後など)にデータを際再取得する場面が多々あります。
このとき、useQuery
の返り値にあるrefetch
をコールしてデータの再取得をすることができます。
// クリック後にデータを再取得する例
const ComponentA = () => {
const { data, refetch } = useQuery({
queryKey: ['hoge', 'fuga'],
staleTime: 5 * 1000 * 60, // キャッシュは5分有効
...
})
// clickされた際、処理を行なってデータの再取得を行う
const handleClick = () => {
...
refetch()
}
return (
<div>
<p>{data.piyo}</p>
<button onClick={handleClick}>click me!</button>
</div>
)
}
しかし、上記例のComponentA
とは別のコンポーネントで呼ばれているuseQuery
のデータも再取得するべき対象であった場合はどうしましょう?
例に挙げると以下の通りです。
-
ComponentA
でclickされたらComponentB
のデータも再取得しないと不整合が起きる -
ComponentB
がマウントされたとき、キャッシュはまだfresh
const ComponentA = () => {
const { data, refetch } = useQuery({
queryKey: ['hoge', 'fuga'],
staleTime: 5 * 1000 * 60, // キャッシュは5分有効
...
})
const handleClick = () => {
...
refetch()
}
return (
<div>
<p>{data.piyo}</p>
<button onClick={handleClick}>click me!</button>
</div>
)
}
const ComponentB = () => {
const { data, refetch } = useQuery({
queryKey: ['hoge2', 'fuga2'],
staleTime: 5 * 1000 * 60, // キャッシュは5分有効
...
})
return (
<div>
<p>{data.piyo2}</p>
</div>
)
}
パッと出てくる解決策としてはこのような感じかなと思います。
-
refetch
をバケツリレーする -
refetchOnMount: 'always'
をオプションに追加して常にマウント時にはデータを再取得する
しかし、コードが汚れてしまったりせっかくのキャッシュ機能をフルに活用できなくなってしまいます。
そこで出てくるのがQuery Invalidation
機能です。
以下のコードは公式ドキュメントの抜粋ですが、queryClient.invalidateQueries
の引数にinvalidateしたい(キャッシュを強制的にstaleとしたい)queryKey
を渡すことで特定のタイミングでデータを勝手に再取得してfreshな状態にしてくれます。
特定のタイミングとは
以下のオプションでtrue
を指定している場合、キャッシュがstaleならば再取得が走ります。
- refetchOnMount
- refetchOnReconnect
- refetchOnWindowFocus
import { useQuery, useQueryClient } from '@tanstack/react-query'
// Get QueryClient from the context
const queryClient = useQueryClient()
queryClient.invalidateQueries({ queryKey: ['todos'] })
// Both queries below will be invalidated
const todoListQuery = useQuery({
queryKey: ['todos'],
queryFn: fetchTodoList,
})
const todoListQuery = useQuery({
queryKey: ['todos', { page: 1 }],
queryFn: fetchTodoList,
})
Query Keysの理解しておくべき重要ポイント
公式ドキュメントのQuery Keys項ではqueryKey
の同値判定の仕組みのみ解説されていますが、Query Invalidationでサラっと説明されている重要な仕組みがあります。
以下のサンプルコードにある通り、queryKey
がtodos
から始まっているもの全てを対象にinvalidateが実行されるという点です。
// Invalidate every query with a key that starts with `todos`
queryClient.invalidateQueries({ queryKey: ['todos'] })
つまり、queryKey
の1つ目の要素に渡す値はReact Queryの機能を活用する上で他の要素より重要な役割を持つということです。
Aspidaとの連携
ここからやっとこの記事の本題に入ります。
Aspidaと一緒にQuery Invalidation
機能をいい感じに使いこなすために、
- Aspidaクライアントの各エンドポイントの
$path
の返り値をqueryKey
の1つ目の要素としてセットできるカスタムフックの作成 -
Query Invalidation
を楽に行なえるカスタムフックの作成
// '$path'はこのような値が返ります
aspidaClient.users.$path() → /users
aspidaClient.users._userId('hoge').$path() → /users/hoge
1. useAspidaQuery
こちらのリポジトリをめちゃくちゃ参考にしています。
型むっず。
-
aspida-react-query
が古かったので最新のReact Queryで動くように型を修正 -
queryKey
の1つ目の要素を$path()
の返り値にする-
aspida-react-query
では$path(opt)
だったため、Query parametersがある場合にうまく動かなかった
-
import {
useQuery,
UseQueryResult,
UseQueryOptions,
} from "@tanstack/react-query";
type QueryOptions<T extends (option: any) => Promise<any>> = Parameters<
Parameters<T> extends [Parameters<T>[0]]
? (
option: Parameters<T>[0] &
UseQueryOptions<ReturnType<T> extends Promise<infer S> ? S : never>
) => void
: (
option?: Parameters<T>[0] &
UseQueryOptions<ReturnType<T> extends Promise<infer S> ? S : never>
) => void
>;
type QueryResult<T extends (option: any) => Promise<any>> = UseQueryResult<
ReturnType<T> extends Promise<infer S> ? S : never,
any
>;
function useAspidaQuery<
T extends Record<string, any> & {
$get: (option: any) => Promise<any>;
$path: (option?: any) => string;
}
>(api: T, ...option: QueryOptions<T["$get"]>): QueryResult<T["$get"]>;
function useAspidaQuery<
T extends Record<string, any> & { $path: (option?: any) => string },
U extends {
[K in keyof T]: T[K] extends (option: any) => Promise<any> ? K : never;
}[keyof T]
>(api: T, key: U, ...option: QueryOptions<T[U]>): QueryResult<T[U]>;
function useAspidaQuery<
T extends Record<string, any> & { $path: (option?: any) => string },
U extends {
[K in keyof T]: T[K] extends (option: any) => Promise<any> ? K : never;
}[keyof T]
>(api: T, key: U, ...option: Parameters<T[U]>) {
const method = typeof key === "string" ? key : "$get";
const opt = typeof key === "string" ? (option as any)[0] : key;
// $pathが同じ場合はキャッシュを共有する
const baseQueryKey =
api.$path() === api.$path(opt)
? [api.$path()]
: [api.$path(), api.$path(opt)];
return useQuery(
typeof key === "string" ? [...baseQueryKey, method] : [...baseQueryKey],
() => api[method](opt),
opt
);
}
export { useAspidaQuery };
2. useInvalidateQueries
引数に渡した$path()
の値をまとめてinvalidateするカスタムフックです。
export const useInvalidateQueries = (...paths: string[]) => {
const queryClient = useQueryClient();
return () => {
paths.forEach((path) => {
queryClient.invalidateQueries({ queryKey: [path] });
});
};
};
一気に共通のドメイン群のinvalidateをするためにテンプレートを用意してあげると便利です。
export const invalidateQueriesTemplate = {
hoge: (hogeId: string) => [
aspidaClient.hoge.$path(),
aspidaClient.fuga.hoge.$path(),
aspidaClient.hoge._hoge(hogeId).$path(),
],
...
};
// このように使える
useInvalidateQueries(...invalidateQueriesTemplate.hoge(hogeId))
まとめ
React Queryは非常に多機能であるため、すべてを把握することは難しいかもしれません。しかし、React Queryを使用することで、キャッシュ制御やデータの取得・更新に関する多くの機能を簡単に実装することができます。
この記事では、React Queryを使用したカスタムフックを実装し、キャッシュの制御方法について紹介しました。ただし、アプリケーションに合わせたパラメータを完全に制御する場合は、queryClientのキャッシュを直接操作することが必要になる場合もあります。
React Queryを使用することで、アプリケーションのデータフローを効率的かつ柔軟に制御することができます。より高度な使い方については、公式ドキュメントやコミュニティの情報を参考にしてください。また、もしもっといい方法や便利なカスタムフックがある場合は、コメントで教えていただけると幸いです。
(まとめを適当に書いたらめっちゃかっこよくChatGPTさんが書きなおしてくれたすげー、、、)
Discussion