株式会社HRBrain
🧠

私の考えるReact Queryベストプラクティス

2022/12/24に公開

はじめに

こんにちは。株式会社HRBrainでインターンをしている安井です。
この記事では今業務で採用しているReact Queryの特性を生かした設計を考察してまとめていきます。

本記事はHRBrain Advent Calendar 2022の24日目の記事です。

https://qiita.com/advent-calendar/2022/hrbrain

前提

本記事で使用するライブラリ等のversion
  • React: v18.2.0
  • TypeScript: v4.9.3
  • React Query(TanStack Query): v4.19.1
  • axios: v1.2.1
  • vite: v4.0.0

React Queryはv4から名前が変更されTanStack Queryとなりましたが、記事の中ではReact Queryと呼ぶこととします。

使用するOpenAPIの定義

また、本記事で使用するコードは下記のレポジトリで公開しています。

https://github.com/taisei-13046/my-best-react-query

React Queryとは

React Query is often described as the missing data-fetching library for React, but in more technical terms, it makes fetching, caching, synchronizing and updating server state in your React applications a breeze.

React Queryは単にデータフェッチをするライブラリではなく、キャッシュ機構、同期通信、サーバの状態更新を可能にする機能を提供してくれています。従来の状態管理ライブラリではclient側の管理を得意としていましたが、serverとのやりとりを解決することに課題がありました。そのような観点でReact Queryは有効に使えるでしょう。

https://tanstack.com/query/v4/docs/overview

また、同系統のライブラリにSWRが挙げられます。
両者の違いとしてはReact Queryの方が多様な機能を提供しているのに対して、SWRは最低限のシンプルさを保っています。その分、バンドルサイズが3倍ほど異なるのでプロジェクトの要件に合わせて技術を選定するのが良さそうです。
それぞれの違いについてはReact Queryの公式ドキュメントにて比較されているのでぜひ参照ください。

https://tanstack.com/query/v4/docs/comparison

React Queryの基本構文

まずはじめに、React Queryの基本構文を確認します。

  // レスポンスの型定義
  interface Posts {
    userId: number;
    id: number;
    title: string;
    body: string;
  }

  type PostsGetResponse = Posts[];

  // Promiseを返す関数
  const fetchPosts = async () => {
    const { data } = await axios.request<PostsGetResponse>({
      url: "/posts",
      method: "GET",
    })
    return data
  }

  /**
   * 投稿の一覧を表示させるページ
   */
  export const PostListPage = () => {
    // useQuery
    const { data: postList } = useQuery(['posts'], fetchPosts)

    return (
      <div>
        {postList?.map(({ id, title, body }) => (
          <div key={id}>
            <p>id: {id}</p>
            <p>title: {title}</p>
            <p>body: {body}</p>
          </div>
        ))}
      </div>
    );
  };

useQueryを使ってPostの一覧を取得し、表示させることに成功しました。
今回は上記のコードを元にして適切な関心ごとに分離し、保守性を高める設計を考えていきたいと思います。

5つの段階に分けてリファクタリング📝

1. カスタムフック化してUI層からロジックを分離する

まずはじめに、API通信を行っているコードをUI層から切り離す事を考えます。
UI層の関心事はUIのみに徹するのが原則です。

*UI層がUI以外の複雑なロジックを持つことはSmart UI(利口なUI)というアンチパターンに該当します。

https://de.wikipedia.org/wiki/Smart_UI

API通信のロジックを持ったコードをUI層から切り離す際には、カスタムフック化することが有効です。

src/
 ├ pages
 |   └ PostList
 |       ├ index.tsx // UIを定義するページコンポーネント
 |       └ usePostList.ts // ページに必要なデータを渡すためのカスタムフック
pages/PostList/index.tsxのコード結果
pages/PostList/index.tsx
import { usePostList } from "./usePostList";

/**
 * 投稿の一覧を表示させるページ
 */
export const PostListPage = () => {
  const { postList } = usePostList();

    return (
      <div>
        {postList?.map(({ id, title, body }) => (
          <div key={id}>
            <p>id: {id}</p>
            <p>title: {title}</p>
            <p>body: {body}</p>
          </div>
        ))}
      </div>
    );
  };
};

pages/PostList/index.tsxのコード結果を確認すると、API通信のロジックが完全になくなりUIのみに関心が向くように改善されました。
pages/PostList/index.tsxの責務が「usePostListから自分がほしいデータをいい感じに取ってきてUIを構築する」という役割のみを担うようになったと思います。

では、そのAPI通信のロジックを持ったカスタムフック(usePostList.ts)を作成していきます。

pages/PostList/usePostList.ts
  // レスポンスの型定義
  interface Posts {
    userId: number;
    id: number;
    title: string;
    body: string;
  }

  type PostsGetResponse = Posts[];

  // Promiseを返す関数
  const fetchPosts = async () => {
    const { data } = await axios.request<PostsGetResponse>({
      url: "/posts",
      method: "GET",
    })
    return data
  }

  export const usePostList = () => {
    const { data: postList } = useQuery(["posts"], fetchPosts);

    return { postList };
  };

先程のAPI通信ロジックをそのまま移行した形になります。

この修正によって確かにUI層からAPI通信のロジックを分離することに成功しました。しかし、これを各ページごとに実装していては同じクエリを実行する関数が重複してしまいます。

一つのエンドポイントに対するuseQueryの処理は共通化することが可能です。
DRY(Don't Repeat Yourself)原則に則ってページごとのカスタムフックからuseQueryの実装を切り離し、共通化することが次の課題になります。

https://ja.wikipedia.org/wiki/Don't_repeat_yourself

2. 各エンドポイントに対応するuseQueryのhooksを作成して共通化する

今回の/postsに対応するクエリ実行処理はどのページでも共通です。
そのため、各エンドポイントに該当するuseQueryの処理をhooks化し共通化することで不要な重複を避けるようにします。

src/
 ├ api
 |  └ posts // OpenAPIのエンドポイントに合わせる
 |      ├ hooks.ts // ⭐new!
 |      └ index.ts 
 | 
 ├ pages
 |   └ PostList
 |       ├ index.tsx // UIを定義するページコンポーネント
 |       └ usePostList.ts // ページに必要なデータを渡すためのカスタムフック

apiディレクトリを作成してその配下にposts/hooks.tsを配置しました。
私の場合、API層はOpenAPIのエンドポイントに合わせてディレクトリを命名し、その配下にロジックを置くことでOpenAPIとの関連性を高めるようにしています。

では、api/posts/hooks.tsを作成していきます。

api/posts/hooks.ts
  // レスポンスの型定義
  interface Posts {
    userId: number;
    id: number;
    title: string;
    body: string;
  }

  type PostsGetResponse = Posts[];

  // Promiseを返す関数
  const fetchPosts = async () => {
    const { data } = await axios.request<PostsGetResponse>({
      url: "/posts",
      method: "GET",
    })
    return data
  }

  export const usePostsQuery = () => {
    return useQuery(["posts"], fetchTodos);
  };
pages/PostList/usePostList.tsのコード結果
pages/PostList/usePostList.ts
import { usePostsQuery } from "../../api/posts";

export const usePostList = () => {
  const { data: postList } = usePostsQuery();

  return { postList };
};

pages/PostList/usePostList.tsのコード結果をみると、usePostsQueryを実行するだけの処理になりました。

もしも、APIからのレスポンスを加工したい場合はusePostList.tsの中で処理することになりますが、各エンドポイントに対するuseQueryの処理をhooksに共通化したことによって、usePostList.tsの責務が「API層のhooksを使って良しなにデータを取得し、ページで必要な形に加工して渡す」という、server-stateとページの中継役に徹することができました。

次の課題点としてはAPI層のhooks.tsの関心事が適切に分離されているかどうかです。

3. queryKeyを一意に保つ

React QueryにおいてqueryKeyを一意に保つことはとても重要です。

The most important part is that keys need to be unique for your queries. If React Query finds an entry for a key in the cache, it will use it. Please also be aware that you cannot use the same key for useQuery and useInfiniteQuery. There is, after all, only one Query Cache, and you would share the data between these two. That is not good because infinite queries have a fundamentally different structure than "normal" queries.

TkDodo's Blogより

React QueryはqueryKeyを頼りにキャッシュを管理しています。
そのため、queryKeyの重複は意図しない挙動を生む可能性があります。

では、queryKeyの一意性を担保するような実装をしていきましょう。

src/
 ├ api
 |  └ posts // OpenAPIのエンドポイントに合わせる
 |      ├ hooks.ts
 |      ├ queryKey.ts // ⭐new!
 |      └ index.ts 
 | 
 ├ pages
 |   └ PostList
 |       ├ index.tsx // UIを定義するページコンポーネント
 |       └ usePostList.ts // ページに必要なデータを渡すためのカスタムフック

api/posts/queryKey.tsというファイルを作成しました。
コードは以下のようになります。

api/posts/queryKey.ts
  export const postsKeys = {
    all: ["posts"] as const,
    // 仮にPostsの1件を取得するクエリがあった場合
    detail: (id: number) => [...postsKeys.all, id] as const,
  };

ここでは/postsのエンドポイントで使用されるqueryKeyをまとめて管理しています。
私は普段queryKeyの命名をOpenAPIのエンドポイントに合わせることで意図しない重複を防ぐように心がけています。
(例) queryKeyのprefix(接頭辞)をOpenAPIのエンドポイントと合わせるなど

また、postsKeysを別ファイルに切り出しexportしている理由は、React Queryの強力な機能であるinvalidate処理で使用することを想定したためです。

invalidateとは

  // Invalidate every query with a key that starts with `posts`
  queryClient.invalidateQueries({ queryKey: ['posts'] })

When a query is invalidated with invalidateQueries, two things happen:

  • It is marked as stale. This stale state overrides any staleTime configurations being used in useQuery or related hooks
  • If the query is currently being rendered via useQuery or related hooks, it will also be refetched in the background

invalidateとは「明示的に特定のクエリをstaleさせる(キャッシュが古くなったとみなす)ことで、refetch処理を走らせ最新のデータを取得する」ことです。これはmutation実行時(データ更新時)、それに紐づく最新のクエリを取得したい場合などに有効です。

ここで注意する必要があるのは、invalidateQueriesの引数に['posts']のようなqueryKeyを直書きで渡している点です。先程も述べましたがReact QueryはqueryKeyによってキャシュが管理されています。

そのため、上記のようにinvalidateQueriesの引数にqueryKeyを直書きしてしまうと、queryKeyに変更があった際に影響範囲が拡大してしまいます。

そこで、queryKeyをまとめて管理し外部のmoduleに対してexoprtすることで、invalidate処理など特定のqueryKeyを指定する際にtypoを防ぎ、変更があっても影響範囲を1箇所に留めることができます。

  // api/posts/queryKey.tsからimport
  queryClient.invalidateQueries(postsKeys.all)

https://tanstack.com/query/v4/docs/guides/query-invalidation

https://tkdodo.eu/blog/effective-react-query-keys

https://tanstack.com/query/v4/docs/guides/query-keys

4. データフェッチ処理(queryFn)をuseQueryから分離させる

次はqueryFnについてです。

React Query is loved by many for drastically simplifying data fetching in React applications. So it might come as a bit of a surprise if I tell you that React Query is in fact NOT a data fetching library.
It doesn't fetch any data for you, and only a very small set of features are directly tied to the network (like the OnlineManager, refetchOnReconnect or retrying offline mutation). This also becomes apparent when you write your first queryFn, and you have to use something to actually get the data, like fetch, axios, ky or even graphql-request.

TkDodo's Blogより

React Queryは正確にはデータフェッチライブラリではありません。

なぜなら、useQueryのqueryFnにはfetchやaxios、kyといったライブラリを使用する必要があり、実際にserverからデータを取得する役割はこれらが担っています。

React Query is an async state manager. It can manage any form of asynchronous state - it is happy as long as it gets a Promise.

TkDodo's Blogより

そのうえで、React Queryの役割は「非同期の状態を管理するもの」と定義することができるでしょう。
axiosなどを使用して非同期にデータを取得し、それらをcacheに保存することでアプリ全体から参照することができるようになるからです。

そのため、本質的にはqueryFnの関数はuseQueryから分離されていることが望ましいです。
(仮にaxiosからkyなどにライブラリを移行してもReact Queryと分離されていることで影響範囲をqueryFnの中に留めることができます。)

src/
 ├ api
 |  └ posts // OpenAPIのエンドポイントに合わせる
 |      ├ hooks.ts
 |      ├ queryKey.ts
 |      ├ queryFn.ts // ⭐new!
 |      └ index.ts 
 | 
 ├ pages
 |   └ PostList
 |       ├ index.tsx // UIを定義するページコンポーネント
 |       └ usePostList.ts // ページに必要なデータを渡すためのカスタムフック

api/posts/queryFn.tsを作成していきます。

api/posts/queryFn.ts
  interface Posts {
    userId: number;
    id: number;
    title: string;
    body: string;
  }

  export type PostsGetResponse = Posts[];

  /**
   * GET /posts
   */
  export const fetchPosts = async () => {
    const { data } = await axios.request<PostsGetResponse>({
      url: "/posts",
      method: "GET",
    });
    return data;
  };
api/posts/hooks.ts(usePostsQuery)のコード結果
api/posts/hooks.ts
  import { useQuery } from "@tanstack/react-query";
  import { fetchPosts } from "./queryFn";
  import { postsKeys } from "./queryKey";

  export const usePostsQuery = () => {
    return useQuery(postsKeys.all, fetchPosts);
  };

api/posts/hooks.tsのコード結果をみると、とてもスッキリしたと思います。
また、それぞれが適切な関心事に分離されているため可読性が高く変更に強い実装になりました。

しかし、現状usePostsQueryを使用する側からuseQueryのoptionsを指定することができません。次の課題はuseQueryのoptionsをusePostsQueryを使用する側から指定できるようにして、最大限React Queryの強みを活かせるようにすることです。

5. useQueryのoptionsを指定できるようにする

api/posts/hooks.tsで定義したuseQueryのhooksは現状外からoptionsを指定することができません。しかし、useQueryには便利なoptionsが多数存在します。
詳細な説明は割愛しますが、

  • enabled
    • クエリを実行する条件を指定できる
  • onSuccess, onError
    • それぞれ成功時、エラー時に実行する処理を追加できる
  • select
    • queryFnで返却されるデータを加工して返すことができる

などの便利なoptionsが存在します。
より詳細なoptionsについては公式ドキュメントを参照ください。

https://tanstack.com/query/v4/docs/reference/useQuery

では、useQueryのhooksを使用する側からoptionsを指定できるように修正します。

api/posts/hooks.ts
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { fetchPosts } from "./queryFn";
import { postsKeys } from "./queryKey";
import { PostsGetResponse } from "./types";

export const usePostsQuery = <TData = PostsGetResponse>(
  options?: Omit<
    UseQueryOptions<PostsGetResponse, AxiosError, TData, typeof postsKeys.all>,
    "queryKey" | "queryFn"
  >
) => {
  return useQuery(postsKeys.all, fetchPosts, { ...options });
};

少し型定義が複雑になりましたね。
まずは、 そもそものuseQueryの型定義から確認していきましょう。

  export function useQuery<
    // queryFnのreturn型
    TQueryFnData = unknown,
    // エラー型
    TError = unknown,
    /**
     * selectを使用した際のreturn型
     * selectを使用していない場合はTQueryFnDataと一致する
     */
    TData = TQueryFnData,
    // queryKeyの型
    TQueryKey extends QueryKey = QueryKey
  >

では、usePostsQueryにそれぞれ型を当てはめていきます。

  • TQueryFnData
    • TQueryFnDataはqueryFnのreturn型つまり、APIからのレスポンス型と一致します。今回はPostsGetResponseですね。
  • TError
    • 次にTErrorですが、これは使用するデータフェッチライブラリに依存します。今回はaxiosを使用しているのでAxiosErrorを当てはめるのが適当です。
  • TData
    • TDataですが、usePostsQuery側から把握することができません。なぜならselectオプションでどのようにデータを加工し返すかはusePostsQueryを使う側次第だからです。そのため、外部から良しなに型を当てはめられるようにGenericsで定義します。また、selectオプションを使用しない場合のTDataTQueryFnDataと一致するのでdefaultの型はTQueryFnDataに設定しています。
  • TQueryKey
    • TQueryKeyはそのままqueryKey(typeof postsKeys.all)の型になります。

このようにしてusePostsQueryの引数の型を定義するとuseQueryのoptionsを使用できるようになりました。
usePostsQueryの使用例は以下のようになります。

pages/PostList/usePostList.ts
  import { PostsGetResponse, usePostsQuery } from "../../api/posts";

  // clientで管理したい型定義
  interface Post {
    id: number;
    title: string;
    body: string;
  }

  // serverのレスポンスをclientで扱う形に変換
  const postListTranslator = (data: PostsGetResponse): Post[] =>
    data.map(({ id, title, body }) => ({
      id,
      // タイトルを全て大文字に
      title: title.toUpperCase(),
      body,
    }));

  export const usePostList = () => {
    const { data: postList } = usePostsQuery<
      Post[]
    >({
      select: postListTranslator,
    });

    return { postList };
  };

ここでは、selectオプションにpostListTranslator、つまりserverからのレスポンスをclient側で管理したい形に変換する関数を渡しています。

TkDodo's Blogの中でもuseQueryで取得するデータの変換については、selectオプション内で行うことがbetterだと書かれています。理由としてはselectオプションはdataが存在する場合にのみ実行されるため、undefinedを考慮する必要がない などいくつかあります。

https://tkdodo.eu/blog/react-query-data-transformations

まとめ

以上でコードのリファクタリングは終わりです。
そして今回私がReact Queryの設計を考える上で強調したいことは以下の5点です。

  • UI層からAPI通信するロジックを分離してカスタムフック化する
  • 各エンドポイントに対応するuseQueryの実装を共通化する
  • queryKeyを一意に保つ
  • queryFnをuseQueryから分離させる
  • useQueryのhooksに対してoptionsを指定できるようにする

また、今回の設計によりそれぞれのファイルに対して的確な責務を与えることができました。

ファイル名 責務
pages/PostList/index.tsx UIを構築
pages/PostList/usePostList.ts ページに渡すデータを取得、加工
api/posts/hooks.ts エンドポイントに対応するuseQuery処理を実装
api/posts/queryKey.ts queryKeyを管理
api/posts/queryFn.ts データフェッチ処理を実装

いかがでしたでしょうか?

この設計はReact QueryのMutationにおいても同様に対応できるかと思います。
また、今回触れなかったReact Queryのキャッシュ機構やReact18以降のSuspenseなども組み合わせることでより強力なアプリケーションを開発することができるでしょう。

https://tanstack.com/query/v4/docs/overview

https://tanstack.com/query/v4/docs/community/tkdodos-blog

最後に

最後まで読んで頂きありがとうございました。
HRBrainでは一緒に働く仲間を募集しています。
弊社に興味を持った方がいればぜひ下記ページからご応募ください!!

https://www.hrbrain.co.jp/recruit

株式会社HRBrain
株式会社HRBrain

Discussion