🐥

React Queryを扱う上でviewとロジックを分離するディレクトリ設計

2022/08/07に公開

React Queryを使ってみて、設計に関して考えてみたので書いていきます。

※注意点
今回はReact Queryの使い方や一番の持ち味であるキャッシュに関しては特に深ぼっていきません。
いかに分かりやすく、変化に強い設計ができるかをテーマに進めていきます。

React Queryの使い方

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

async function fetchUser(userId: number) {
  const response = await fetch(
    `http://localhost:5000/users/${userId}`
  );
  return response.json();
}
const { data, isError, error, isLoading, isFetching } = useQuery(
    'user',
    () => fetchUser(userId)
  );

基本的な使い方はこんな感じです。
getリクエストをする際にuseQueryを使用し、第一引数にkeyを指定し、第二引数にfetch処理をした関数を渡していきます。

従来ようにLoadingなどをstateとして管理しuseEffectで関数を実行しなくても短い処理で書けるので非常に便利です。

しかし、こちらのコードには問題点がたくさんあります。

問題点

まずはエンドポイントをハードコーディングしている点です。
コンポーネント内部にエンドポイントをハードコーディングしていると、エンドポイントが変わった際に、全てのファイルのエンドポイントを変更しなければなりません。

そして二つ目はコンポーネント内部に全ての処理が書かれている点です。
これでは、処理が増えてくると、コンポーネント内のロジックが肥大化し、可読性が失われてしまいます。

そして何より、TypeScriptの持ち味である、型のドキュメント化が全くできていません。
このようにfetch処理が各ファイルに散らばっていると、このアプリケーションにどんなapiがあるのかが、ソースコードをみただけでは全く判断する事ができません。

上記の観点をこれから改善していきます。

ディレクトリ設計

以下のようなディレクトリ設計にしました。

.
├── src
│   ├── apis
│   │   ├── gateways      
│   │   ├── hooks
│   │   ├── implements     
│   │   ├── requests         
│   │   ├── responses

まずapiに関する処理をapisに全てまとめていきます。
第三者がapiの内容を確認する時にみるディレクトリはここだけです。

各ディレクトリの役割は下記です。

  • gateways ... 主にfetchのメイン処理を書いていく
  • hooks ... カスタムフックをまとめる
  • implements ... interfaceを実装する(これがドキュメントの役割をする)
  • requests ... requestの型定義をまとめる
  • responses ... responseの型定義をまとめる

言葉で見てもあまりイメージがつかないかもしれないので、実装していきましょう。
今回はuserデータのcrud処理をテーマにして行なっていきます。

userの型定義は下記です

export type UserRole = "ADMIN" | "NORMAL_USER";

export type User = {
  id: number;
  email: string;
  name: string;
  role: UserRole;
  description: string;
  avatarUri?: string;
}

requests, responses, implements

requests ... requestの型定義をまとめる
responses ... responseの型定義をまとめる
implements ... interfaceを実装する(これがドキュメントの役割をする)
まずはこの部分から実装していきます。

src/apis/requests/FindUsersListRequest.ts
export type FindUsersListRequest = {
  paginationPageNumber: number;
  itemsCountPerPaginationPage: number;
  searchByUserName: string | null;
}

まずrequestsのファイルにはrequestになる型定義を書いていきます。
今回はuserをpagenationで取得したいと思います。

src/apis/responses/FindUsersListResponse.ts
import { User } from "../../types/User";

export type FindUsersListResponse = {
  itemsCountInSelection: number;
  totalItemsCount: number;
  users: User[];
}

responseの型定義はこちらに書いていきます。

src/apis/implements/UserApiImpl.ts
import { User } from "../../types/User";

import { FindUsersListRequest } from "../requests/FindUsersListRequest";
import { FindUsersListResponse } from "../responses/FindUsersListResponse";

export interface UserApiImpl {
  findList: (query: FindUsersListRequest) => Promise<FindUsersListResponse>

  findById: (userId: number) => Promise<User>

  create: (requestBody: Omit<User, "id">) => Promise<number>

  update: (requestBody: User) => Promise<void>

  delete: (userId: number) => Promise<void>
}

そしてこちらのファイルでinterfaceを実装します。
これを実装するだけで、このアプリケーションに何のapiがあるかひと目でわかります。

このファイルであれば、
あっユーザー関連のapiはcurdが一通りあって、一覧の取得の処理はpagenationとユーザー名での検索があるのか!となり
これがTypeScriptの型のドキュメント化の持ち味です。

gateways

gateways ... 主にfetchのメイン処理を書いていく

こちらには先ほどのinterfaceを継承して、apiのfetch処理を書いていきます。

src/apis/gateways/UserApi.ts
import axios from "axios";

import { User } from "../../types/User";
import { UserApiImpl } from "../implements/UserApiImpl";
import { FindUsersListRequest } from "../requests/FindUsersListRequest";
import { ApiResponse } from "../responses/ApiResponse";
import { FindUsersListResponse } from "../responses/FindUsersListResponse";

class UserApi implements UserApiImpl {
  public async findList(query: FindUsersListRequest): Promise<FindUsersListResponse> {
    const res: ApiResponse<FindUsersListResponse> = await axios.get("http://localhost:5000/users", {
      params: {
        paginationPageNumber: query.paginationPageNumber,
        itemsCountPerPaginationPage: query.itemsCountPerPaginationPage,
        ...query.searchByUserName ? { searchByUserName: query.searchByUserName } : undefined
      }
    });

    return res.data.data;
  }

  public async findById(userId: number): Promise<User> {
    const res: ApiResponse<User> = await axios.get(`http://localhost:5000/users/${userId}`);
    return res.data.data;
  }

  public async create(requestBody: Omit<User, "id">): Promise<number> {
    const url = `http://localhost:5000/users/`
    const res = await axios.post(url, {
      email: requestBody.email,
      name: requestBody.name,
      role: requestBody.role,
      description: requestBody.description,
      avatarUri: requestBody.avatarUri ? requestBody.avatarUri : undefined
    });

    return (res.data).data.id;
  }

  public async update(requestBody: User): Promise<void> {
    const url = `http://localhost:5000/users/${requestBody.id}`
    await axios.patch(url, {
      id: requestBody.id,
      email: requestBody.email,
      name: requestBody.name,
      role: requestBody.role,
      description: requestBody.description,
      avatarUri: requestBody.avatarUri ? requestBody.avatarUri : undefined
    });
  }

  public async delete(userId: number): Promise<void> {
    const url = `http://localhost:5000/users/${userId}`
    await axios.delete(url);
  }
}

export const userApi = new UserApi();

クラスで定義し、先ほどのinterfaceをimplementsする事で、型安全に実装する事ができます。
apiの処理をこちらに一つにまとめる事で、変化に強くなります。

例えばエンドポイントが変わった際は、一番最初の例だと、各ファイルのエンドポイントを変更しなければなかったですが、こちらであれば、1箇所を変えるだけで修正完了です。

またaxiosではなくfetch関数を使いたい、GraphQLに移行したいなどはこちらのファイルを修正するだけで、コンポーネント内は影響を受けなくなります。

hooks

hooks ... カスタムフックをまとめる

最後にカスタムフックスにまとめていきます。
再利用する処理は積極的にカスタムフックスにまとめていくべきです。
例えばユーザー取得処理であれば、必ずユーザー詳細ページ、編集ページで2回しようすることになります。
こちらを両方のページで書くのは明らかなハードコーディングです。

他のロジックもviewとロジックを分離できるようカスタムフックスにしていきましょう。

src/apis/hooks/useUser.ts
import { useMutation, useQuery } from "@tanstack/react-query";
import { User } from "../../types/User";
import { userApi } from "../gateways/UserApi";
import { FindUsersListResponse } from "../responses/FindUsersListResponse";
import { FindUsersListRequest } from "../requests/FindUsersListRequest";

export const useQueryUsers = (query: FindUsersListRequest) => {
  return useQuery<FindUsersListResponse, Error>({
    queryKey: ["users", query.searchByUserName, query.paginationPageNumber],
    queryFn: () => userApi.findList({
      paginationPageNumber: query.paginationPageNumber,
      itemsCountPerPaginationPage: 5,
      searchByUserName: query.searchByUserName
    })
  })
}

export const useQueryUser = (userId: number) => {
  return useQuery<User, Error>({
    queryKey: ["useUser", userId],
    queryFn: () => userApi.findById(userId)
  })
}

export const useMutateUser = () => {

  const createUser = useMutation((user: Omit<User, "id">) => userApi.create(user))
  const updateUser = useMutation((user: User) => userApi.update(user))
  const deleteUser = useMutation((userId: number) => userApi.delete(userId))

  return { createUser, updateUser, deleteUser }
}

先ほどのgatewaysで書いたclassをfetcher処理として呼び出しているだけです。
本来であればこちらでキャッシュの設定等を書いても良いと思います。
今回は割愛します。

使ってみる

使う際はこれだけです。


// ユーザー一覧取得
const { isLoading, error, data } = useQueryUsers({
  searchByUserName: searchName,
  paginationPageNumber: pageNumber,
  itemsCountPerPaginationPage: 15
})

// ユーザー詳細取得
const { data: user, error, isLoading } = useQueryUser(userId)

// 追加、更新、削除
const { updateUser, deleteUser, createUser } = useMutateUser()

// 追加の一例
createUser.mutate({
  name: inputValue.name,
  email: inputValue.email,
  role: inputValue.role,
  description: inputValue.description
}, {
  onSuccess: (userId) => router.push(`/users/${userId}`),
  onError: (error) => console.log(error)
})

component内では上記のロジックを呼び出すだけです。
MutationはcreateUser.mutateのように呼び出すことができ、onSuccessで成功後の処理、onErrorでエラーハンドリングが書けます。

こちらはhooks内でもかけますが、書くべきではありません。
custom hooksは自分がどこから呼ばれるか事前に知っていてはなりません。

どこに呼ばれるかによって、成功時とエラー時のハンドリングは変わってくるからです。

SWRに移行して、実際に変化に強いか試してみる

React Queryとよく比べられるものにSWRがあります。
例えばSWRがアップデートされ、明らかに使い心地がよくなって移行したいとなったとしましょう。
こちらの設計でいかに楽に書き換えができるか試していきます。

一番楽そうなuser取得処理を書き換えていきます。

src/apis/hooks/useUser.ts
export const useQueryUser = (userId: number) => {
  // return useQuery<User, Error>({
  //   queryKey: ["useUser", userId],
  //   queryFn: () => userApi.findById(userId)
  // })
  return useSWR(userId ? String(userId) : null, () => userApi.findById(userId))
}

これでほぼ完了です、、、
あとは呼び出す際に、swrはisLoadingを用意していないので、少し修正する必要がありますがこれだけです。
他のファイルは一切編集を加える必要がありません!

// const { data: user, error, isLoading } = useQueryUser(userId)
  const { data: user, error } = useQueryUser(userId) // 書き換え
  const isLoading = !user && !error; // 追加

個人的にはswrとりReact Queryの方が使いやすいです!

https://github.com/abeshi03/react-query
詳しい概要はgithubに上がっています。

Discussion