React Queryを使いこなすために試したこと
はじめに
タイトルで大きくふろしきを広げてしまいましたが、結論から言うといろいろと試してみた結果、現時点ではまだ着地点を見出せていません。現時点での自分なりの最適解として、useQuery
、usePrefetch
、useMutation
と楽観的更新の実装例についていくつか紹介させていただきます。
モチベーション
現在、Redux Sagaをふんだんに利用したアプリケーションのメンテナンス・機能拡張に携わっているのですが、ページ数やAPIエンドポイントが多数あることからコード量が多く構造も複雑になっており、メンテナンスコストの増大が懸念されるようになってきました。そこで今後のメンテナンス性の向上、また新規に参画するメンバーにも入りやすいようRedux SagaをはがしReact Queryへ少しずつ移行してくための検証を兼ねてReact Queryの使い方を探っています。
React Query
React Queryはデータの取得やキャッシュを用いた状態の管理を便利に行える多機能なライブラリです。一般的な使い方としては以下のように他のQuery系?ライブラリ(SWRやRTK Query,apollo client)と同じように、useEffect
を利用しなくてもコロケーションを意識した実装ができます。実装する箇所によってはこのままでも十分実用的ですが、アプリケーションの規模が大きくなるとカスタムフック化するなど抽象化が必要になってくるでしょう。
const { data, isError, isLoading } = useQuery(['tasks'], getTasks)
if(isLoading) return <div>Loading...</div>
if(isError) return <div>Error</div>
return (
<ul>
{!!data && data.map(item => <li key={item.id}>{item.title}</div>)}
</ul>
)
検証環境
- React v18.2.0
- @tanstack/react-query 4.0.10
- TypeScript v4.6.3
- vite v2.9.7
下記に記事内の例にも利用しているサンプルプログラムを公開しています。入れ替えを想定しているアプリケーションとはまったく異なるのですが、サンプルはGoogle Tasks APIを利用したものです。よろしければこちらもあわせて参照ください。
useQuery
React Queryを利用する場合ほとんどはuseQuery
を利用したデータの取得と状態の管理になります。今回、Sagaから移行をしたいアプリケーションも8割はuseQuery
を利用した形で置き換えられそうです。
一番シンプルな使い方としては、useQuery
の第1引数に識別用のユニークなQuery Keys
、第2引数にデータ取得用の関数を置く形でしょう。(React Query v4からはQuery Keys
を配列で渡す必要があります)
たとえばタスク一覧(ex:taskListId
)に登録されたタスクの一覧を取得する場合は下記のようになりますが、
const { data, isError, isLoading } = useQuery(['tasks'], () => getTasks(taskListId))
これを今回は下記のように抽象化した形での利用を想定して考えてみました。
const { data, isError, isLoading } = useFetchTasks(taskListId);
useQuery
を抽象化するuseApi
今回のAPIへの接続にはアクセストークンを利用することを想定しました。useApi
という共通のラッパーを用意し、useApi
を利用したカスタムフックを各エンドポイントごとに作っていきます。実装を検討しはじめた時は少し複雑になっていましたが最終的にほとんど切り離し結果的には以下のようなシンプルな形となりました。
第1引数にQuery Keys
、第2引数にデータ取得用の関数、第3引数にはuseQuery
のオプションを任意で指定できるようにします。
export const useApi = <
TQueryKey extends [string, (Record<string, unknown> | string)?],
TQueryFnData,
TError,
TData = TQueryFnData,
>(
queryKey: TQueryKey,
fetcher: (params: TQueryKey[1], token: string) => Promise<TQueryFnData>,
options?: Omit<
UseQueryOptions<unknown, TError, TData, TQueryKey>,
'queryKey' | 'queryFn'
>,
) => {
// accessTokenを何らかの形で取得する
const { accessToken } = useAuthGuardContext();
return useQuery({
queryKey,
queryFn: async () => fetcher(queryKey[1], accessToken || ''),
...options,
});
};
第2引数にはカスタムフック側からデータ取得用の関数を渡しますが、その中でアクセストークン等が不要な場合はそれらのロジックは取り除いてしまいます。SupabaseなどSaaSへ接続を想定している場合はここで認証情報を付与することはないかもしれません。
なお、React Queryが有する型についてはReact Queryのメンテナーでもある@TkDodo氏のブログにある下記記事が参考になります。
useFetchTasks
カスタムフックuseApi
を使ったカスタムフックを用途別に用意します。第1引数にQuery Keys
としてtasks
とパラメーターを2つ目のキーとして渡すようにしています。
const useFetchTasks = (taskListId: string) =>
useApi(
// 第1引数 QueryKey、2番めにはデータ取得関数に渡すパラメーターを渡す
// パラメーターがそのまま QueryKeyとなる
['tasks', { taskListId }],
// 第2引数 第1引数の2番目の値がそのままデータ取得用の1つ目の引数に入る
// 2つ目にはuseApiで取得したaccessTokenが入る
async ({ taskListId }, token) =>
// APIからデータ取得用のrepositoryなどへ渡す
tasksRepository.getTasks({ taskListId }, token),
// 第3引数 useQueryのオプション
{
enabled: !!taskListId,
},
);
これでコンポーネント側からは下記のように使うことができます。
const { data, isError, isLoading } = useFetchTasks(taskListId);
なお、Query Keys
の1つ目は固定、2つ目にパラメーターを同定に渡すことでキーの生成を半自動化しています。React QueryではQuery Keys
を誤ると意図しない動作になる可能性があるのためその点は注意する必要があります。型で固定という手もあるかもしれませんが、ラッパーが決まった型で固定されてしまうのは避けたいので、下記のようなファクトリー関数を用意しておくのもひとつの方法です。
const tasksKeys = {
all: ['tasks'] as const,
lists: () => [...tasksKeys.all, 'list'] as const,
list: (filters: string) => [...tasksKeys.lists(), { filters }] as const,
details: () => [...tasksKeys.all, 'detail'] as const,
detail: (id: number) => [...tasksKeys.details(), id] as const,
}
// キーに['tasks','list']を反映する場合
queryClient.invalidateQueries(todoKeys.lists())
usePrefetch
React Queryにはプリフェッチ用の関数も用意されています。タスク一覧に配置されている各タスクのエリアへマウスカーソルが入った場合に該当のタスク詳細情報を事前に取得し、タスク詳細画面へ遷移する前にキャッシュへ情報を格納するという想定です。
prefetchQuery
を抽象化するusePrefetch
第1引数にQuery Keys
、第2引数にデータ取得用の関数を指定できるようにします。
export const usePrefetch = <
TQueryKey extends [string, Record<string, unknown>?],
TQueryFnData,
TError,
TData = TQueryFnData,
>(
queryKey: TQueryKey,
fetcher: (params: TQueryKey[1], token: string) => Promise<TQueryFnData>,
options?: Omit<
UseQueryOptions<unknown, TError, TData, TQueryKey>,
'queryKey' | 'queryFn'
>,
) => {
const { accessToken } = useAuthGuardContext();
const queryClient = useQueryClient();
return () => {
if (!queryKey[0]) {
return;
}
queryClient.prefetchQuery({
queryKey,
queryFn: async () => fetcher(queryKey[1], accessToken || ''),
...options,
});
};
};
usePrefetchTask
カスタムフックusePrefetch
を利用したカスタムフックを用意します。カスタムフック自体の構造はuseQuery
と同様です。プリフェッチする先のQuery Keys
は遷移先の画面で利用するキーと同じ値にする必要があります。
詳細画面のキーが['task', {taskListId: 'hoge', taskId: 'fuga'}]
のようになる想定。
const usePrefetchTask = (taskListId: string, taskId: string) =>
usePrefetch(
// 第1引数 QueryKey、2番めにはデータ取得関数に渡すパラメーターを渡す
// パラメーターがそのまま QueryKeyとなる
['task', { taskListId, taskId }],
// 第2引数 第1引数の2番目の値がそのままデータ取得用の1つ目の引数に入る
// 2つ目にはuseApiで取得したaccessTokenが入る
async (params, token) =>
tasksRepository.getTask(params, token),
);
コンポーネント側ではタスク名のBoxコンポーネントにonMouseEnter
を検知してリンク先へ飛ぶ前にプリフェッチを行うようにしています。
const prefetched = useRef<boolean>();
const prefetchTask = usePrefetchTask(taskListId, task.id);
<Box
onMouseEnter={() => {
if (!prefetched.current) {
prefetchTask();
prefetched.current = true;
}
}}
>
<Link to={`taskDetail/${taskId}`}>タスク名</Link>
</Box>
プリフェッチの動作イメージ
「牛乳を買ってくる」はプリフェッチを無効としたコンポーネントになっており、詳細ページへ遷移時にローディングが走ります。「ヨーグルトを買ってくる」はプリフェッチが有効となっており、遷移時にローディングが走らず即座にデータが表示されているのがわかると思います。
useMutation
次に更新用のuseMutation
です。useMutation
では各種作成や更新のやり取りを行います。今回は単純に作成・更新のみを行うuseGenericMutation
と楽観的な更新用途に対応させたuseOptimisticMutation
の2つを用意しました。
useMutation
を抽象化するuseGenericMutation
通常のMutationを行う版のラッピング用関数です。第1引数にデータ取得用の関数、第2引数にはuseMutation
のオプションを任意で指定できるようにします。
export const useGenericMutation = <TVariables, TData, TContext>(
fetcher: (params: TVariables, token: string) => Promise<TData | void>,
options?: UseMutationOptions<TData | void, unknown, TVariables, TContext>,
) => {
const { accessToken } = useAuthGuardContext();
return useMutation(
async (params: TVariables) => {
return await fetcher(params, accessToken || '');
},
{ ...options },
);
};
useAddTask
カスタムフックuseGenericMutation
を利用したカスタムフックを用意します。Query Keys
は不要なためシンプルな形になっています。
useMutation
の返り値に明確な型情報をもたせたいためジェネリクスで補完しています。1つ目は送信パラメーターの型、2つ目はデータ取得関数からの返り値の型、3つ目はデータ全体の型になります。
const useAddTask = (taskListId: string) =>
useGenericMutation<Task, Task, Task[]>(
async (params, token) =>
tasksRepository.createTask({ ...params, taskListId }, token),
);
コンポーネント側では下記のように利用します。フォームからtitle
を取得してmutate
を実行する想定です。なおuseMutation
もuseQuery
と同様にisLoading
やisError
という状態を取得できます。
const createTask = useAddTask(taskListId);
const isLoading: boolean = createTask.isLoading // ローディング状態をbooleanで返す
const isError: boolean = createTask.isError // エラー状態をbooleanで返す
const submitHandler = (values: FormValues) => {
createTask.mutate(
{
id: Math.random().toString(),
title: values.title,
},
{
onSuccess: () => {
// 成功時にキャッシュの更新
queryClient.invalidateQueries(['tasks', { taskListId }]);
// フォームのリセットなど
form.reset();
},
}
)
}
useMutation
を抽象化するuseOptimisticMutation
通常のuseMutation
を利用したものでも利用用途としては十分ですがAPIからの返りを待ってから表示の更新を行うためユーザー体験的には若干物足りなさがあります。React Queryでは楽観的な更新についても実装しやすい設計になっているので、楽観的更新に対応できるものも用意しておきます。
第1引数にQuery Keys
、第2引数にデータ取得用の関数、第3引数にはデータ取得前に表示用データを更新する関数、第4引数にはuseMutation
のオプションを任意で指定できるようにします。
export const useOptimisticMutation = <TVariables, TData, TContext>(
queryKey: [string, Record<string, unknown>?],
fetcher: (params: TVariables, token: string) => Promise<TData | void>,
updater?: ((oldData: TContext, newData: TVariables) => TContext) | undefined,
options?: Omit<
UseMutationOptions<TData | void, unknown, TVariables, TContext>,
'onMutate' | 'onError' | 'onSettled'
>,
) => {
const { accessToken } = useAuthGuardContext();
const queryClient = useQueryClient();
return useMutation(
async (params) => {
return await fetcher(params, accessToken || '');
},
{
// mutationが開始したタイミングで実行
onMutate: async (data) => {
// 事前に走っているリクエストがある場合はキャンセルする
await queryClient.cancelQueries(queryKey);
// 更新前の現在のデータを取得
const previousData = queryClient.getQueryData<TContext>(queryKey);
// 送信予定のデータと更新用の関数を使ってキャッシュデータを更新する
// ここでUI上のデータは仮のデータに書き換えられる
if (previousData && updater) {
queryClient.setQueryData<TContext>(queryKey, () => {
return updater(previousData, data);
});
}
// データ取得前のデータを返す
return previousData;
},
// APIへの更新が失敗した場合に旧データでロールバックする
onError: (err, _, context) => {
queryClient.setQueryData(queryKey, context);
console.warn(err);
},
// すべての処理が終了した際にキャッシュを更新する
// APIから取得成功した場合は仮のデータから取得したデータに更新
// 失敗した場合は旧データに更新
onSettled: () => {
queryClient.invalidateQueries(queryKey);
},
...options,
},
);
};
useOptimisticMutation
の処理のながれ
1.mutationの開始(onMutate)
- 事前に走っているリクエストをキャンセルする
- 更新前の現在のデータを取得
- 送信予定のデータと更新用の関数を使ってキャッシュデータを更新する。ここでUI上のデータは仮のデータに置き換えられユーザーへは反映が成功したように伝わる。
- 更新前のデータを返す
2.エラーの場合
- APIへのデータ更新が失敗した場合はキャッシュデータを更新前のデータに戻す
3.すべての処理が終了(成功もしくはエラーの場合)
- キャッシュを最新のデータに更新する。成功している場合はAPIから取得した最終的なデータへ更新。エラーの場合は更新前のデータへ更新。
useAddTask
(楽観的更新対応版)
カスタムフックuseOptimisticMutation
を利用したカスタムフックを用意します。事前のキャッシュデータを利用するためQuery Keys
が必要となっています。
また、useMutation
の返り値に明確な型情報をもたせたいためジェネリクスで補完しています。1つ目は送信パラメーターの型、2つ目はデータ取得関数からの返り値の型、3つ目はデータ全体の型になります。APIからデータの取得完了前の事前更新にもこの型が利用されます。
const useAddTask = (taskListId: string) =>
useOptimisticMutation<Task, Task, Task[]>(
['tasks', { taskListId }],
async (params, token) =>
tasksRepository.createTask({ ...params, taskListId }, token),
// 登録したタスクを現在のデータの先頭へ挿入した一覧を返す
(oldData, newData) => [newData, ...oldData],
);
他のカスタムフックと同様に、第1引数にQuery Keys
、第2引数にデータ取得用の関数が入りますが、第3引数には仮更新用の関数を配置します。今回はジェネリクスの3つ目にTask[]
とタスクの配列型がとしたため、Task[]
を返す関数を用意します。引数に現在のデータ(oldData:Task[]
)と新規データ(newData:Task
)が渡されるため、それらを使って加工します。今回は配列の先頭に新規データを配置したいので[newData, ...oldData]
としました。データの取得が完了し、新しい配列が返る前にこの値を使ってUIは変更され、取得完了後に正式なデータへと置き換えられます。
以下はフォームからtitle
を登録を想定した場合の使用例になります。
const submitHandler = async (values: FormValues) => {
try {
await createTask.mutateAsync(
{
// idは仮のものを渡しておく
id: Math.random().toString(),
title: values.title,
},
{
// 成功時にフォームをリセット
onSuccess: () => form.reset(),
},
);
} catch (error) {
console.warn(error);
const message =
error instanceof Error ? error.message : 'error connecting to server';
// トーストなどでエラーを返す
showNotification({
title: `Cannot add the task: ${values.title}`,
message,
autoClose: 3000,
color: 'red',
});
}
};
動作イメージ
以下が通常の更新と楽観的更新の動作イメージとなります。UIの調整がまだ必要ではありますが、通常は更新完了後に更新データを再取得して再配置を行います。それに対して楽観的更新では新しいタスクを仮に最上部へ配置し、データ更新完了後に改めて書き換えを行うことでユーザーには違和感を感じさせずにUIを変更することが可能です。
useGenericMutation
を利用の場合(通常の更新)
useOptimisticMutation
を利用の場合(楽観的更新)
useUpdateTask
、useDeleteTask
カスタムフックuseAddTask
と同様に、useUpdateTask
、useDeleteTask
のカスタムフックも作成します。どちらも基本的にはuseAddTask
と同じ用にジェネリクスとupdater
部分を用途にあわせて調整してきます。
// useUpdateTask
const useUpdateTask = (taskListId: string) =>
useOptimisticMutation<Task, Task, Task[]>(
['tasks', { taskListId }],
async (params, token) =>
tasksRepository.updateTask({ ...params, taskListId }, token),
// 該当するタスクの中身を変更したタスク一覧を返す
(oldData, params) => {
return [
...oldData.map((task) => {
if (task.id === params.id) {
return { ...task, ...params };
} else {
return task;
}
}),
];
},
);
// useDeleteTask
const useDeleteTask = (taskListId: string) =>
useOptimisticMutation<Pick<Task, 'id'>, void, Task[]>(
['tasks', { taskListId }],
async ({ id }, token) =>
tasksRepository.deleteTask({ taskListId, taskId: id }, token),
// 該当するタスクを取り除いたタスク一覧を返す
(oldData, params) => [...oldData.filter(({ id }) => id !== params.id)],
);
おわりに
今回、React Queryの抽象化を試すのにあたり改めて公式ドキュメントやTkDodo氏のブログを参考に模索してみましたが、調べれば調べるほどにReact Queryの完成度に改めて感動しました。
結局いろいろと試してはみましたが、現時点では上記のような形で着地しています。まだまだあやしげなところ、悩ましいところ、改修したいところがたくさんあるので、明日には違うつくりになっているかもしれません👻
もし他にも良いアイデアなどありましたらぜひアドバイスいただけたらと思います🙇♂️
参考サイト
Discussion