はじめに
筆者は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)
むしろ他でこれができなかったので、コントリビュートし、実用化までの時間を短くしました.
再帰的な木構造を含むOpenAPIからこれ以上の品質のTypeScriptのコードを生成できる物があったら紹介してほしいです.
以下の設定ファイルを用意して、npx @rtk-query/codegen-openapi openapi-config.ts
を実行すると、この様に余分なコードが一切含まれないRTK Queryのコードが生成されます. その為、OpenAPIを書けば、1行もdata fetchingロジックを書く事がなくなります.
endpointsからhooksまで自動生成できるので、RTK Queryでendpointsの書き方は覚えなくても大丈夫です.
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;
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の使い方
この様に、とても使いやすい感じです.
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の関係性を定義できます.
引用元を少し修正しました
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
このタグは、定義に追加するだけで、addPost
やeditPost
を呼び出すのに成功する度に、getPosts
を呼び出しているコンポーネントがある場合、自動でrefetchしてくれます.
このAutomated Re-fetching
機能は、他のdata fetchingライブラリには実装されていない、特に気にいっている強力な機能です.
サンプル
以下と同じ特徴を持つ要件を満たす疑似コードが、React QueryとRTK Queryではどうなるのかを紹介します
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
のリリースで、OpenAPIにタグを書いている場合は、オプションを有効化すれば、providesTags
とinvalidatesTags
を自動で付与できる様になり、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の挙動を変更する拡張を現在作成中です。
RTK Query + Next.jsの参考リポジトリ
suinさんが作成したリポジトリにパッチを当てたリポジトリを掲載します.
SSR対応済みです.
最後に
RTK Queryの機能はここには書ききれない程多いです.
興味を持った方は詳しくはこちらを参照すると良いでしょう.