🎮

2022年Reactを使ってる人には必ず知っていてほしい最強のdata fetchingライブラリであるRTK Queryの優位性とメンテナ

2022/07/02に公開3

はじめに

筆者はOpenAPIスキーマからRTK Queryのコードを生成するrtk-query-codegen-openapiに2020年頃からコントリビュートを続けていました.
rtk-query-codegen-openapiは現在rtk-incubatorリポジトリではなく、redux-toolkit/packages/rtk-query-codegen-openapiに統合されています.

今回は、RTK Queryを幾つかの現場で本番運用して得られた優位性を公開します.

他ツールとの機能比較

RTK QueryとReact Queryが作成したマトリックスがある為、リンクだけ貼って省略します

RTK Queryが作成したマトリックス
https://redux-toolkit.js.org/rtk-query/comparison#comparing-feature-sets

React Queryが作成したマトリックス
https://react-query.tanstack.com/comparison

Best Futures of RTK Query

他のdata fetchingライブラリと比べても、特に強力である機能を選んで紹介します.

GraphQL Code Generation

GraphQLからコード生成できます

https://redux-toolkit.js.org/rtk-query/usage/code-generation#graphql)

OpenAPI Code Generation

OpenAPIからこれ以上ない程にコンパクトで、型安全で、明瞭なコードを生成できます.

https://redux-toolkit.js.org/rtk-query/usage/code-generation#openapi)

:::note info
むしろ他でこれができなかったので、コントリビュートし、実用化までの時間を短くしました.
再帰的な木構造を含むOpenAPIからこれ以上の品質のTypeScriptのコードを生成できる物があったら紹介してほしいです.
:::

以下の設定ファイルを用意して、npx @rtk-query/codegen-openapi openapi-config.tsを実行すると、この様に余分なコードが一切含まれないRTK Queryのコードが生成されます. その為、OpenAPIを書けば、1行もdata fetchingロジックを書く事がなくなります.

:::note info
endpointsからhooksまで自動生成できるので、RTK Queryでendpointsの書き方は覚えなくても大丈夫です.
:::

openapi-config.ts
import type { ConfigFile } from "@rtk-query/codegen-openapi";

// https://redux-toolkit.js.org/rtk-query/usage/code-generation#simple-usage
const config: ConfigFile = {
  schemaFile: "https://petstore3.swagger.io/api/v3/openapi.json",
  apiFile: "./store/emptyApi.ts",
  apiImport: "emptySplitApi",
  outputFile: "./store/petApi.ts",
  exportName: "petApi",
  hooks: true,
};

export default config;
petApi.ts
import { emptySplitApi as api } from "./emptyApi";
const injectedRtkApi = api.injectEndpoints({
  endpoints: (build) => ({
    updatePet: build.mutation<UpdatePetApiResponse, UpdatePetApiArg>({
      query: (queryArg) => ({ url: `/pet`, method: "PUT", body: queryArg.pet }),
    }),
    addPet: build.mutation<AddPetApiResponse, AddPetApiArg>({
      query: (queryArg) => ({
        url: `/pet`,
        method: "POST",
        body: queryArg.pet,
      }),
    }),
    findPetsByStatus: build.query<
      FindPetsByStatusApiResponse,
      FindPetsByStatusApiArg
    >({
      query: (queryArg) => ({
        url: `/pet/findByStatus`,
        params: { status: queryArg.status },
      }),
    }),
    findPetsByTags: build.query<
      FindPetsByTagsApiResponse,
      FindPetsByTagsApiArg
    >({
      query: (queryArg) => ({
        url: `/pet/findByTags`,
        params: { tags: queryArg.tags },
      }),
    }),
    getPetById: build.query<GetPetByIdApiResponse, GetPetByIdApiArg>({
      query: (queryArg) => ({ url: `/pet/${queryArg.petId}` }),
    }),
    updatePetWithForm: build.mutation<
      UpdatePetWithFormApiResponse,
      UpdatePetWithFormApiArg
    >({
      query: (queryArg) => ({
        url: `/pet/${queryArg.petId}`,
        method: "POST",
        params: { name: queryArg.name, status: queryArg.status },
      }),
    }),
    deletePet: build.mutation<DeletePetApiResponse, DeletePetApiArg>({
      query: (queryArg) => ({
        url: `/pet/${queryArg.petId}`,
        method: "DELETE",
        headers: { api_key: queryArg.apiKey },
      }),
    }),
    uploadFile: build.mutation<UploadFileApiResponse, UploadFileApiArg>({
      query: (queryArg) => ({
        url: `/pet/${queryArg.petId}/uploadImage`,
        method: "POST",
        body: queryArg.body,
        params: { additionalMetadata: queryArg.additionalMetadata },
      }),
    }),
    getInventory: build.query<GetInventoryApiResponse, GetInventoryApiArg>({
      query: () => ({ url: `/store/inventory` }),
    }),
    placeOrder: build.mutation<PlaceOrderApiResponse, PlaceOrderApiArg>({
      query: (queryArg) => ({
        url: `/store/order`,
        method: "POST",
        body: queryArg.order,
      }),
    }),
    getOrderById: build.query<GetOrderByIdApiResponse, GetOrderByIdApiArg>({
      query: (queryArg) => ({ url: `/store/order/${queryArg.orderId}` }),
    }),
    deleteOrder: build.mutation<DeleteOrderApiResponse, DeleteOrderApiArg>({
      query: (queryArg) => ({
        url: `/store/order/${queryArg.orderId}`,
        method: "DELETE",
      }),
    }),
    createUser: build.mutation<CreateUserApiResponse, CreateUserApiArg>({
      query: (queryArg) => ({
        url: `/user`,
        method: "POST",
        body: queryArg.user,
      }),
    }),
    createUsersWithListInput: build.mutation<
      CreateUsersWithListInputApiResponse,
      CreateUsersWithListInputApiArg
    >({
      query: (queryArg) => ({
        url: `/user/createWithList`,
        method: "POST",
        body: queryArg.body,
      }),
    }),
    loginUser: build.query<LoginUserApiResponse, LoginUserApiArg>({
      query: (queryArg) => ({
        url: `/user/login`,
        params: { username: queryArg.username, password: queryArg.password },
      }),
    }),
    logoutUser: build.query<LogoutUserApiResponse, LogoutUserApiArg>({
      query: () => ({ url: `/user/logout` }),
    }),
    getUserByName: build.query<GetUserByNameApiResponse, GetUserByNameApiArg>({
      query: (queryArg) => ({ url: `/user/${queryArg.username}` }),
    }),
    updateUser: build.mutation<UpdateUserApiResponse, UpdateUserApiArg>({
      query: (queryArg) => ({
        url: `/user/${queryArg.username}`,
        method: "PUT",
        body: queryArg.user,
      }),
    }),
    deleteUser: build.mutation<DeleteUserApiResponse, DeleteUserApiArg>({
      query: (queryArg) => ({
        url: `/user/${queryArg.username}`,
        method: "DELETE",
      }),
    }),
  }),
  overrideExisting: false,
});
export { injectedRtkApi as petApi };
export type UpdatePetApiResponse = /** status 200 Successful operation */ Pet;
export type UpdatePetApiArg = {
  /** Update an existent pet in the store */
  pet: Pet;
};
export type AddPetApiResponse = /** status 200 Successful operation */ Pet;
export type AddPetApiArg = {
  /** Create a new pet in the store */
  pet: Pet;
};
export type FindPetsByStatusApiResponse =
  /** status 200 successful operation */ Pet[];
export type FindPetsByStatusApiArg = {
  /** Status values that need to be considered for filter */
  status?: "available" | "pending" | "sold";
};
export type FindPetsByTagsApiResponse =
  /** status 200 successful operation */ Pet[];
export type FindPetsByTagsApiArg = {
  /** Tags to filter by */
  tags?: string[];
};
export type GetPetByIdApiResponse = /** status 200 successful operation */ Pet;
export type GetPetByIdApiArg = {
  /** ID of pet to return */
  petId: number;
};
export type UpdatePetWithFormApiResponse = unknown;
export type UpdatePetWithFormApiArg = {
  /** ID of pet that needs to be updated */
  petId: number;
  /** Name of pet that needs to be updated */
  name?: string;
  /** Status of pet that needs to be updated */
  status?: string;
};
export type DeletePetApiResponse = unknown;
export type DeletePetApiArg = {
  apiKey?: string;
  /** Pet id to delete */
  petId: number;
};
export type UploadFileApiResponse =
  /** status 200 successful operation */ ApiResponse;
export type UploadFileApiArg = {
  /** ID of pet to update */
  petId: number;
  /** Additional Metadata */
  additionalMetadata?: string;
  body: Blob;
};
export type GetInventoryApiResponse = /** status 200 successful operation */ {
  [key: string]: number;
};
export type GetInventoryApiArg = void;
export type PlaceOrderApiResponse =
  /** status 200 successful operation */ Order;
export type PlaceOrderApiArg = {
  order: Order;
};
export type GetOrderByIdApiResponse =
  /** status 200 successful operation */ Order;
export type GetOrderByIdApiArg = {
  /** ID of order that needs to be fetched */
  orderId: number;
};
export type DeleteOrderApiResponse = unknown;
export type DeleteOrderApiArg = {
  /** ID of the order that needs to be deleted */
  orderId: number;
};
export type CreateUserApiResponse = unknown;
export type CreateUserApiArg = {
  /** Created user object */
  user: User;
};
export type CreateUsersWithListInputApiResponse =
  /** status 200 Successful operation */ User;
export type CreateUsersWithListInputApiArg = {
  body: User[];
};
export type LoginUserApiResponse =
  /** status 200 successful operation */ string;
export type LoginUserApiArg = {
  /** The user name for login */
  username?: string;
  /** The password for login in clear text */
  password?: string;
};
export type LogoutUserApiResponse = unknown;
export type LogoutUserApiArg = void;
export type GetUserByNameApiResponse =
  /** status 200 successful operation */ User;
export type GetUserByNameApiArg = {
  /** The name that needs to be fetched. Use user1 for testing.  */
  username: string;
};
export type UpdateUserApiResponse = unknown;
export type UpdateUserApiArg = {
  /** name that need to be deleted */
  username: string;
  /** Update an existent user in the store */
  user: User;
};
export type DeleteUserApiResponse = unknown;
export type DeleteUserApiArg = {
  /** The name that needs to be deleted */
  username: string;
};
export type Category = {
  id?: number;
  name?: string;
};
export type Tag = {
  id?: number;
  name?: string;
};
export type Pet = {
  id?: number;
  name: string;
  category?: Category;
  photoUrls: string[];
  tags?: Tag[];
  status?: "available" | "pending" | "sold";
};
export type ApiResponse = {
  code?: number;
  type?: string;
  message?: string;
};
export type Order = {
  id?: number;
  petId?: number;
  quantity?: number;
  shipDate?: string;
  status?: "placed" | "approved" | "delivered";
  complete?: boolean;
};
export type User = {
  id?: number;
  username?: string;
  firstName?: string;
  lastName?: string;
  email?: string;
  password?: string;
  phone?: string;
  userStatus?: number;
};
export const {
  useUpdatePetMutation,
  useAddPetMutation,
  useFindPetsByStatusQuery,
  useFindPetsByTagsQuery,
  useGetPetByIdQuery,
  useUpdatePetWithFormMutation,
  useDeletePetMutation,
  useUploadFileMutation,
  useGetInventoryQuery,
  usePlaceOrderMutation,
  useGetOrderByIdQuery,
  useDeleteOrderMutation,
  useCreateUserMutation,
  useCreateUsersWithListInputMutation,
  useLoginUserQuery,
  useLogoutUserQuery,
  useGetUserByNameQuery,
  useUpdateUserMutation,
  useDeleteUserMutation,
} = injectedRtkApi;

生成されたHooksの使い方

この様に、とても使いやすい感じです.

pet.tsx
import { NextPage } from "next";
import { useFindPetsByStatusQuery } from "../store/petApi";

const Pet: NextPage = (props) => {
  const { data, error, isLoading } = useFindPetsByStatusQuery({
    status: "available",
  });

  return (
    <div>
      <h1>Petstore</h1>
      <p>
        <a href="https://redux-toolkit.js.org/rtk-query/usage/code-generation">
          see the tutorial
        </a>
      </p>
      <div>
        {error ? (
          <>Oh no, there was an error</>
        ) : isLoading ? (
          <>Loading...</>
        ) : data ? (
          <>
            <pre>{JSON.stringify(data, null, 2)}</pre>
          </>
        ) : null}
      </div>
    </div>
  );
};

export default Pet;

引用元

Skipの仕方

1.skipオプションを使う

import { useGetPostQuery } from './api'

function MaybePost({ id }: { id?: number }) {
  // This will produce a typescript error:
  // Argument of type 'number | undefined' is not assignable to parameter of type 'number | unique symbol'.
  // Type 'undefined' is not assignable to type 'number | unique symbol'.

  // @ts-expect-error id passed must be a number, but we don't call it when it isn't a number
  const { data } = useGetPostQuery(id, { skip: !id })

  return <div>...</div>
}

2.skipTokenを使う

import { skipToken } from '@reduxjs/toolkit/query/react'
import { useGetPostQuery } from './api'

function MaybePost({ id }: { id?: number }) {
  // When `id` is nullish, we will still skip the query.
  // TypeScript is also happy that the query will only ever be called with a `number` now
  const { data } = useGetPostQuery(id ?? skipToken)

  return <div>...</div>
}

refetchOnMountOrArgChange

apiの引数が全く同一の場合、前回のfetchの結果がGCされていない場合は、前回の結果をすぐさま取得し、再fetchしません.
refetchOnMountOrArgChangeオプションを利用すると、前回の結果を使いつつ、新しい結果の取得が可能になります.

import { useGetPostsQuery } from './api'

const Component = () => {
  const { data } = useGetPostsQuery(
    { count: 5 },
    // this overrules the api definition setting,
    // forcing the query to always fetch when this component is mounted
    { refetchOnMountOrArgChange: true }
  )

  return <div>...</div>
}

Automated Re-fetching

以下の様にendpointにタグ付けする事により、Endpointの関係性を定義できます.

引用元を少し修正しました

sample.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { Post, User } from './types'

const api = createApi({
  baseQuery: fetchBaseQuery({
    baseUrl: '/',
  }),
  tagTypes: ['Post', 'User'],
  endpoints: (build) => ({
    getPosts: build.query<Post[], void>({
      query: () => '/posts',
      providesTags: ['Post'],
    }),
    getUsers: build.query<User[], void>({
      query: () => '/users',
      providesTags: ['User'],
    }),
    addPost: build.mutation<Post, Omit<Post, 'id'>>({
      query: (body) => ({
        url: 'post',
        method: 'POST',
        body,
      }),
      invalidatesTags: ['Post'],
    }),
    editPost: build.mutation<Post, Partial<Post> & Pick<Post, 'id'>>({
      query: (body) => ({
        url: `post/${body.id}`,
        method: 'POST',
        body,
      }),
      invalidatesTags: ['Post'],
    }),
  }),
})

export const {
  useGetPostsQuery,
  useGetUsersQuery,
  useAddPostMutation,
  useEditPostMutation,
} = api

このタグは、定義に追加するだけで、addPosteditPostを呼び出すのに成功する度に、getPostsを呼び出しているコンポーネントがある場合、自動でrefetchしてくれます.

Shared Private Board@2x (2).png

このAutomated Re-fetching機能は、他のdata fetchingライブラリには実装されていない、特に気にいっている強力な機能です.

サンプル

以下と同じ特徴を持つ要件を満たす疑似コードが、React QueryとRTK Queryではどうなるのかを紹介します

Shared Private Board@2x (1).png

React Query

引用元

react queryでは、refetchをしたいタイミングで、以下の様にqueryClient.refetchQueriesを利用する必要があります.
なので、例えばDELETE: /itemsをした場合、queryClient.refetchQueriesを忘れずに毎回呼び出す必要があります.

function Page() {
  const result = useQuery(
    key,
    async () => {
      await sleep(10)
      return 'fetched'
    },
    {
      initialData: 'initial',
      staleTime: Infinity,
    }
  )

  return (
    <div>
      <div>isFetching: {result.isFetching}</div>
      <button onClick={() => queryClient.refetchQueries(key)}>
        refetch
      </button>
      data: {result.data}
    </div>
  )
}

RTK Query

RTK Queryではrefetchのコードを1行も書く必要がありません.
editPostが成功した場合、getPostは自動でrefetchされます.
しかも、hooksもendpoint定義も、全て生成可能です。

import { useGetPostQuery, useEditPostMutation } from "./sample.ts"

function Page() {
  const { data, error, isFetching } = useGetPostQuery()
  const [editPost] = useEditPostMutation()

  return (
    <div>
      <div>isFetching: {isFetching}</div>
      <button onClick={() => editPost.unwrap()}>
        edit post
      </button>
      data: {data}
    </div>
  )
}

Automated Re-fetchingの為のタグのコードを生成

私が作成した以下のPRがマージされました.

rtk-query-codegen-openapi: If tags are specified, they are now mapped to providesTags or invalidatesTags.

つまり、次の@rtk-query/codegen-openapiのリリースで、OpenAPIにタグを書いている場合は、オプションを有効化すれば、providesTagsinvalidatesTagsを自動で付与できる様になり、RTK Queryを利用している現場のフロントエンドエンジニアはrefetchのコードを自分で書く事はほぼ無いでしょう.

Customizing queries

この例では fetchBaseQuery をラップして、 401 Unauthorized エラーが発生したときに追加のリクエストを送信して認証トークンの更新を試み、再認証後に最初のクエリを再試行するようにしています。

引用元

import { fetchBaseQuery } from '@reduxjs/toolkit/query'
import type {
  BaseQueryFn,
  FetchArgs,
  FetchBaseQueryError,
} from '@reduxjs/toolkit/query'
import { tokenReceived, loggedOut } from './authSlice'

const baseQuery = fetchBaseQuery({ baseUrl: '/' })
const baseQueryWithReauth: BaseQueryFn<
  string | FetchArgs,
  unknown,
  FetchBaseQueryError
> = async (args, api, extraOptions) => {
  let result = await baseQuery(args, api, extraOptions)
  if (result.error && result.error.status === 401) {
    // try to get a new token
    const refreshResult = await baseQuery('/refreshToken', api, extraOptions)
    if (refreshResult.data) {
      // store the new token
      api.dispatch(tokenReceived(refreshResult.data))
      // retry the initial query
      result = await baseQuery(args, api, extraOptions)
    } else {
      api.dispatch(loggedOut())
    }
  }
  return result
}

また、以下の様に指定した回数だけretryする様にする様にする事も可能です.

import { createApi, fetchBaseQuery, retry } from '@reduxjs/toolkit/query/react'
interface Post {
  id: number
  name: string
}
type PostsResponse = Post[]

// maxRetries: 5 is the default, and can be omitted. Shown for documentation purposes.
const staggeredBaseQuery = retry(fetchBaseQuery({ baseUrl: '/' }), {
  maxRetries: 5,
})
export const api = createApi({
  baseQuery: staggeredBaseQuery,
  endpoints: (build) => ({
    getPosts: build.query<PostsResponse, void>({
      query: () => ({ url: 'posts' }),
    }),
    getPost: build.query<PostsResponse, string>({
      query: (id) => ({ url: `post/${id}` }),
      extraOptions: { maxRetries: 8 }, // You can override the retry behavior on each endpoint
    }),
  }),
})

export const { useGetPostsQuery, useGetPostQuery } = api

また、特定のhttp statusやerror時のresponse bodyにキーとなる情報が含まれている場合にretryの挙動を変更する拡張を現在作成中です。

https://github.com/reduxjs/redux-toolkit/issues/2175

RTK Query + Next.jsの参考リポジトリ

suinさんが作成したリポジトリにパッチを当てたリポジトリを掲載します.
SSR対応済みです.

https://github.com/kahirokunn/nextjs-redux-tool-kit/tree/rtk

最後に

RTK Queryの機能はここには書ききれない程多いです.
興味を持った方は詳しくはこちらを参照すると良いでしょう.

https://redux-toolkit.js.org/tutorials/rtk-query

Discussion

Daichi ImamoriDaichi Imamori

初めまして。
こちらの記事が大変参考になり、RTK Query の利用を検討しています。
実際に動かしてみて検証をしているのですが、OpenAPI schema 内で request body や response を snake_case で定義している場合に、generated code 内で camel case に変換する方法がないかを探しています。
コードを読んでみたのですが、既存の機能にはなさそうかなという印象を受けたのですが、もし kahirokunn さんがこの辺りご存じであれば、何か情報をいただけると幸いです。

Daichi ImamoriDaichi Imamori

返信ありがとうございます!
現時点では該当の機能がないということで承知いたしました、issue を作成してみますね。
ありがとうございました!