🐥

React Queryを使いこなすために試したこと

2022/05/08に公開約14,800字

はじめに

タイトルで大きくふろしきを広げてしまいましたが、結論から言うといろいろと試してみた結果、現時点ではまだ着地点を見出せていません。現時点での自分なりの最適解として、useQueryusePrefetchuseMutationと楽観的更新の実装例についていくつか紹介させていただきます。

モチベーション

現在、Redux Sagaをふんだんに利用したアプリケーションのメンテナンス・機能拡張に携わっているのですが、ページ数やAPIエンドポイントが多数あることからコード量が多く構造も複雑になっており、メンテナンスコストの増大が懸念されるようになってきました。そこで今後のメンテナンス性の向上、また新規に参画するメンバーにも入りやすいようRedux SagaをはがしReact Queryへ少しずつ移行してくための検証を兼ねてReact Queryの使い方を探っています。

React Query

React Queryはデータの取得やキャッシュを用いた状態の管理を便利に行える多機能なライブラリです。一般的な使い方としては以下のように他のQuery系?ライブラリ(SWRやRTK Query,apollo client)と同じように、useEffectを利用しなくてもコロケーションを意識した実装ができます。実装する箇所によってはこのままでも十分実用的ですが、アプリケーションの規模が大きくなるとカスタムフック化するなど抽象化が必要になってくるでしょう。

React Queryの基本的な使用例
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>
)

https://react-query.tanstack.com/

検証環境

  • React v18.1.0
  • React Query v4.0.0-beta.7
  • TypeScript v4.6.3
  • vite v2.9.7

下記に記事内の例にも利用しているサンプルプログラムを公開しています。入れ替えを想定しているアプリケーションとはまったく異なるのですが、サンプルはGoogle Tasks APIを利用したものです。よろしければこちらもあわせて参照ください。

GoogleTasksApp

https://github.com/himorishige/react-googletasks-example

useQuery

React Queryを利用する場合ほとんどはuseQueryを利用したデータの取得と状態の管理になります。今回、Sagaから移行をしたいアプリケーションも8割はuseQueryを利用した形で置き換えられそうです。

https://react-query.tanstack.com/guides/queries

一番シンプルな使い方としては、useQueryの第1引数に識別用のユニークなQuery Keys、第2引数にデータ取得用の関数を置く形でしょう。(React Query v4からはQuery Keysを配列で渡す必要があります)

https://react-query.tanstack.com/guides/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のオプションを任意で指定できるようにします。

useApi.ts
export const useApi = <
  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'
  >,
) => {
  // accessTokenを何らかの形で取得する
  const { accessToken } = useAuthGuardContext();

  return useQuery({
    queryKey,
    queryFn: async () => fetcher(queryKey[1], accessToken || ''),
    ...options,
  });
};

第2引数にはカスタムフック側からデータ取得用の関数を渡しますが、その中でアクセストークン等が不要な場合はそれらのロジックは取り除いてしまいます。SupabaseなどSaaSへ接続を想定している場合はここで認証情報を付与することはないかもしれません。

なお、React Queryが有する型についてはReact Queryのメンテナーでもある@TkDodo氏のブログにある下記記事が参考になります。

https://tkdodo.eu/blog/react-query-and-type-script

カスタムフックuseFetchTasks

useApiを使ったカスタムフックを用途別に用意します。第1引数にQuery Keysとしてtasksとパラメーターを2つ目のキーとして渡すようにしています。

useTasks.ts
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にはプリフェッチ用の関数も用意されています。タスク一覧に配置されている各タスクのエリアへマウスカーソルが入った場合に該当のタスク詳細情報を事前に取得し、タスク詳細画面へ遷移する前にキャッシュへ情報を格納するという想定です。

プリフェッチのイメージ

https://react-query.tanstack.com/guides/prefetching

prefetchQueryを抽象化するusePrefetch

第1引数にQuery Keys、第2引数にデータ取得用の関数を指定できるようにします。

useApi.ts
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'}]のようになる想定。

useTasks.ts
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つを用意しました。

https://react-query.tanstack.com/guides/mutations

useMutationを抽象化するuseGenericMutation

通常のMutationを行う版のラッピング用関数です。第1引数にデータ取得用の関数、第2引数にはuseMutationのオプションを任意で指定できるようにします。

useApi.ts
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つ目はデータ全体の型になります。

useTasks.ts
const useAddTask = (taskListId: string) =>
  useGenericMutation<Task, Task, Task[]>(
    async (params, token) =>
      tasksRepository.createTask({ ...params, taskListId }, token),
  );

コンポーネント側では下記のように利用します。フォームからtitleを取得してmutateを実行する想定です。なおuseMutationuseQueryと同様にisLoadingisErrorという状態を取得できます。

コンポーネント側での使用例
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では楽観的な更新についても実装しやすい設計になっているので、楽観的更新に対応できるものも用意しておきます。

https://react-query.tanstack.com/guides/optimistic-updates

第1引数にQuery Keys、第2引数にデータ取得用の関数、第3引数にはデータ取得前に表示用データを更新する関数、第4引数にはuseMutationのオプションを任意で指定できるようにします。

useApi.ts
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からデータの取得完了前の事前更新にもこの型が利用されます。

useTasks.ts
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を利用の場合(楽観的更新)

楽観的更新

カスタムフックuseUpdateTaskuseDeleteTask

useAddTaskと同様に、useUpdateTaskuseDeleteTaskのカスタムフックも作成します。どちらも基本的にはuseAddTaskと同じ用にジェネリクスとupdater部分を用途にあわせて調整してきます。

useTasks.ts
// 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の完成度に改めて感動しました。

結局いろいろと試してはみましたが、現時点では上記のような形で着地しています。まだまだあやしげなところ、悩ましいところ、改修したいところがたくさんあるので、明日には違うつくりになっているかもしれません👻

もし他にも良いアイデアなどありましたらぜひアドバイスいただけたらと思います🙇‍♂️

参考サイト

https://react-query.tanstack.com/
https://tkdodo.eu/blog/
https://www.smashingmagazine.com/2022/01/building-real-app-react-query/
GitHubで編集を提案

Discussion

ログインするとコメントできます