株式会社メドレーDeveloper Portal

2023-03-31

Tanstack Query を活用したフロントエンドアーキテクチャの紹介 ~ 持続可能な開発を目指して ~

こんにちは。医療プラットフォーム第一本部プロダクト開発室所属エンジニアの髙橋です。 普段の業務では、医療 SaaS プラットフォーム CLINICS の医療機関向けアプリケーション(以下、CLINICS)の開発を担当しています。

CLINICS では、昨年 10 月頃から React コードベースのリアーキテクチャに取り組んでいます。 その取り組みの 1 つとして、非同期状態管理に関連する実装を Tanstack Query を使って刷新しています。

この記事では、CLINICS における Tanstack Query の活用方法を導入背景と狙いを含めて紹介します。

目次

Tanstack Query について

Tanstack Query は、Web アプリケーションのサーバ状態の取得、キャッシュ、同期、更新を簡単に行うことができるライブラリです。1

類似ライブラリには SWR、Apollo Client、RTK Query 等が挙げられます。2

私たちは、機能性・ドキュメントの充実度・コミュニティの将来性を総合的に判断した結果、Tanstack Query(React Query)を採用して React コードベースの再構築を進めています。 導入背景の詳細は、後のセクションで詳しく紹介します。

Tanstack Query を活用したフロントエンドアーキテクチャ

それでは本題の Tanstack Query を活用したフロントエンドアーキテクチャについて紹介します。 始めに、アーキテクチャの全体像を示した次の図をご覧ください。

Tanstack Query を活用したフロントエンドアーキテクチャ

まず注目して頂きたいポイントは、Backend と View の間にある Resource Operation です。

Resource Operation は Tanstack Query を主軸に実装されているレイヤーです。 役割を大きく分類すると次の 2 つが挙げられます。

  1. 非同期状態管理に関連する実装の集約
  2. Backend で扱うデータ(OpenAPI スキーマ)と View で扱うデータ(ViewModel)の相互変換

全体像を把握するために、左右のレイヤーにも注目してください。

右の Backend は CLINICS では Ruby on Rails で実装された REST API を提供するモノリシックなサーバです。 API で扱うスキーマは OpenAPI を使って定義しています。 OpenAPI スキーマは、committee-rails を使ったレスポンス検証と、openapi-generator を使った API クライアントコードの自動生成に活用しています。

左の View は CLINICS では React で実装されたコンポーネントになります。 View では Backend の OpenAPI スキーマを直接参照することを避け、 ViewModel と呼ばれるフロントエンドで定義したモデル3のみを参照する設計としています。

Resource Operation は View と Backend の境界レイヤーとして非同期状態管理とデータの相互変換を行うシンプルなレイヤーとなっています。

続いて、上記の運用のための実装詳細を紹介します。

Resource Operation の実装

ディレクトリ構成

Resource Operation レイヤーでは、リソースごとに非同期状態管理とデータの相互変換の処理をまとめています。

ディレクトリツリーで Resource Operation レイヤー全体を表現すると次のようになります。

src/resourceOperations
├── resourceTagName      # 例: todo
│   ├── cache.ts
│   ├── mutations.ts
│   └── queries.ts
└── resourceGroupTagName # 例: systemSettings(リソースに階層がある場合)
    └── resourceTagName  # 例: organization(リソースグループ配下のリソース)
        ├── cache.ts
        ├── mutations.ts
        └── queries.ts

ディレクトリ名の resourceTagNameresourceGroupTagName は OpenAPI スキーマでエンドポイントごとに割り振られている tag を元に命名しています。

ディレクトリごとに 次の 3 つのファイルを定義しています。

ファイル役割
cache.tsQuery Key の定義、キャッシュ操作のためのカスタムフックの定義
queries.tsuseQuery をラップしたカスタムフックの定義、API Request/Response の整形
mutations.tsuseMutation をラップしたカスタムフックの定義、API Request の整形

cache.ts

cache.ts は次のように実装しています。

// ① Query Key
// queries.ts がある場合に必要に応じて宣言
export const todoKeys = {
  all: ["todo"] as const,
  list: () => [...todoKeys.all, "list"] as const,
  paginateList: (page?: number) => [...todoKeys.list(), { page }] as const,
  detail: (id: string) => [...todoKeys.all, "detail", id] as const,
};

// ② キャッシュ操作のためのカスタムフック
// mutations.ts がある場合に必要に応じて宣言
export function useTodoCache() {
  const queryClient = useQueryClient();

  return useMemo(
    () => ({
      invalidateList: () => queryClient.invalidateQueries(todoKeys.list()),
      invalidateDetail: (id: string) =>
        queryClient.invalidateQueries(todoKeys.detail(id)),
    }),
    [queryClient]
  );
}

① Query Key は、useQueryqueryKey オプションに与える値を定義した定数です。これは後述の queries.ts で利用します。 実装は Tanstack Query メンテナの Dominik さんのブログで紹介されている Query Key factories パターンを使っています。 ディレクトリ名を all の値とすることで、大規模なアプリケーションにおいてもキーの衝突を防ぐことが可能です。

② キャッシュ操作のためのカスタムフックでは QueryClient を利用したキャッシュ操作をまとめています。これは後述の mutations.ts で利用します。 CLINICS では楽観的更新をしない方針としているため、 invalidateQueries を実行する関数群のみを定義しています。

queries.ts

queries.ts は次のように実装しています。

import { type Todo } from "@/viewModels/todo";
import { todoApi, type GetTodoDetailRequest } from "@/api/generated";

// ③ queryFn
export const query = {
  getTodoList: async (): Promise<Todo[]> => {
    const { data } = await todoApi.getTodoList();

    return data.todoList;
  },
  getTodoDetail: async (request: GetTodoDetailRequest): Promise<Todo> => {
    const { data } = await todoApi.getTodoDetail(request);

    return data.todo;
  },
};

// ④ Request Selector
export const request = {
  getTodoDetail: (id: string | undefined): GetTodoDetailRequest => {
    if (id === undefined) {
      // `enabled: false` となる条件の引数が与えられた場合例外とすることで、
      // queryFn 内の型の整合性を保つ
      throw new InvalidRequestParameterError(
        "Required parameter id was undefined when calling request.getTodoDetail."
      );
    }
    return {
      id,
    };
  },
};

③ queryFn は、API へのリクエストを責務とした関数群です。 ここで使用している ApiClient や Request の型は openapi-generator から生成しています。 queryFn の戻り値の型は、後述の ViewModel で定義した型を明示しています。 このようにフロントエンド側でサーバステートの型を別途定義することで、フロントエンドとバックエンドを分業して実装する際に開発しやすくなるメリットがあります。 省略していますが、レスポンスに応じたエラーの throw もここで行います。

④ Request Selector は View から受け取った値をリクエストパラメータへマッピングすることを責務とした関数群です。 useQuery の条件付き実行を制御する enabled オプションで false となる条件を例外とすることで、queryFn 内で 型ガードをしなくて良い設計としています。

これらを使って View とのインターフェースとなる useQuery ラッパーを定義します。

// ⑤ Base Query
// API Response を整形せずに返す
export type UseTodoDetailQueryProps<QueryResult> = {
  id: string | undefined;
  select?: (data: Todo) => QueryResult;
};

export function useTodoDetailQuery<QueryResult = Todo>(
  props: UseTodoDetailQueryProps<QueryResult>
) {
  return useQuery({
    queryKey: todoKeys.detail(props.id),
    queryFn: () => {
      return query.getTodoDetail(request.getTodoDetail(props.id));
    },
    enabled: !!props.id,
    select: props.select,
    useErrorBoundary: true,
  });
}

import { selectTodoForm } from "@/viewModels/todo/todoForm";

// ⑥ Selector Query
// API Response を View で参照したいフォーマットに整形して返す
export function useInitialTodoFormQuery() {
  // Base Query に select オプションを与える
  return useTodoDetailQuery({
    select: selectTodoForm, // selectTodoForm は Todo を TodoForm に変換する ViewModel Selector(後述)
  });
}

useQuery ラッパーは、⑤ Base Query と ⑥ Selector Query に分けて定義しています。 CLINICS では同じデータソースに対して画面ごとに異なるフォーマットで表示することが多くあります。 Selector Query で 任意のフォーマットに整形することで、画面ごとに最適化されたデータの取得をスケーラブルに実現しています。4

加えて、この手法では Selector にドメインロジックが凝集されるため、Selector を重点的にテストすることで品質を担保しやすいメリットがあります。

Query のエラー制御は useErrorBoundary オプションを使って Error Boundary を表示する方針としています。

mutations.ts

mutations.ts は次のように実装しています。

import { type TodoForm } from "@/viewModels/todo/todoForm";
import { todoApi, type PostTodoRequest } "@/api/generated";

// ⑦ mutationFn
export const mutation = {
  createTodo: (request: PostTodoRequest) => {
    return todoApi.postTodo(request);
  },
};

// ⑧ Request Selector
// ViewModel から API Request へ変換する
export const request = {
  createTodo: (todoForm: TodoForm): PostTodoRequest => {
    return {
      PostTodoRequest: {
        todo: {
          title: todoForm.title,
          description: todoForm.description,
          status: todoForm.status,
          favorite: todoForm.favorite === "true" ? true : false,
        },
      },
    };
  },
};

// ⑨ Custom Mutation
export function useCreateTodoMutation() {
  const { invalidateList } = useTodoCache();

  return useMutation({
    mutationFn: (todoForm: TodoForm) => {
      return mutation.createTodo(request.createTodo(todoForm));
    },
    onSuccess: () => {
      return invalidateList();
    },
  });
}

基本的な構成は queries.ts と同じです。

mutations.ts でも queries.ts と同様に ⑧ Request Selector を定義します。 Request Selector には、データ整形を扱うためドメインロジックが集まりやすいです。 そのため、入出力、境界値、例外のテストを積極的に書いて品質の担保に繋げています。

Mutation のエラーは画面ごとに UI でフィードバックするため、コンポーネント側で制御しています。

Resource Operation レイヤーに関する実装の紹介は以上です。

ViewModel の実装

ViewModel とは、View(React Component) で扱うデータのスキーマと型です。

Resource Operation の実装では、API Response を ViewModel に整形して View に提供することを紹介しました。

全体の理解を深めるため、ViewModel の実装も紹介します。

ディレクトリ構成

ViewModel は関心を分離するため Resource Operation とは別のディレクトリに定義しています。

src/viewModels
└── todo
    ├── todo.ts                # サーバステート
    ├── todoForm.test.ts       # フロントエンドに閉じたスキーマのテスト
    ├── todoForm.ts            # フロントエンドに閉じたスキーマ
    └── todoSearchCondition.ts # フロントエンドに閉じた型

ViewModel で定義するスキーマ・型は大きく分けて 3 つに分類されます。

  • サーバステート
  • フロントエンドに閉じたスキーマ
  • フロントエンドに閉じた型

サーバステート

サーバステートは、 API Response として期待するデータのスキーマ及び型です。 前述の queries.ts 内の ③ queryFn で使用します。

zod を使って次のように実装しています。

// src/viewModels/todo/todo.ts

// enum は View で <option value={todoStatus.enum.ready} /> のように使用する
export const todoStatus = z.enum(["ready", "doing", "done"]);

// ⑩ サーバステートのスキーマ
// 開発中のみ ③ queryFn 内で API Response を parse することで、スキーマの不整合を検出するための補助輪として使用する
export const todo = z.object({
  id: z.string(),
  title: z.string(),
  description: z.string().nullable(),
  status: todoStatus,
  favorite: z.boolean(),
});

// ⑪ サーバステートの型
// ③ queryFn の戻り値の型として使用する
export type Todo = z.infer<typeof todo>;

⑩ サーバステートのスキーマは、⑪ サーバステートの型の生成と開発時のスキーマ検証に使用しています。

開発時のみ API Response を検証することで、⑩ サーバステートのスキーマと Open API スキーマの整合性を確認しています。

// src/resourceOperations/todo/queries.ts
import { todo, type Todo } from "@/viewModels/todo";

export const query = {
  getTodoList: async (): Promise<Todo[]> => {
    const { data } = await todoApi.getTodoList();

    // 開発時、APIとの結合タイミングで検証してフロントエンドとバックエンドでスキーマの齟齬がないことを確認する
    // 検証が済んだら parse 処理を外す
    return data.todoList.map((x) => todo.parse(x));
  },
};

例外として、外部サービスから取得したデータに関しては常にサーバステートのスキーマを使って検証しています。 例えば、外部サービスの仕様として長さが 1 以上の配列が返ってくると決まっていて、フロントエンド側もその仕様に基づいた処理を実装している場合、 z.array().min(1) のスキーマで常に検証します。 ネットワークに近い箇所で不正なデータを検出することで、例外発生時の調査を容易にするメリットがあると考えています。

フロントエンドに閉じたスキーマ

フロントエンドに閉じたスキーマは、そのほとんどがフォームのバリデーションスキーマです。 こちらも zod を使って定義しています。

// src/viewModels/todo/todoForm.ts

import { type Todo, todo } from "./todo";

// フロントエンドに閉じたスキーマ
// フォームのスキーマは react-hook-form と連携して使用する(省略)
export const todoForm = z.object({
  title: z
    .string()
    .min(1, "入力してください")
    .max(200, "200字以内で入力してください"),
  description: z.string().max(500, "500字以内で入力してください"),
  status: todo.shape.status,
  favorite: z.union([z.literal("true"), z.literal("false")]),
});

export type TodoForm = z.infer<typeof todoForm>;

// ⑫ ViewModel Selector
// サーバステートからフロントエンドに閉じたスキーマへ変換する
// 前述の queries.ts 内 ⑥ Selector Query で使用する
export function selectTodoForm(todo: Todo): TodoForm {
  return {
    title: todo.title,
    description: todo.description ?? "",
    status: todo.status,
    favorite: todo.favorite ? "true" : "false",
  };
}

フロントエンドに閉じたスキーマは、必ずサーバステートを元に生成する運用としています。 そのために、フロントエンドに閉じたスキーマを宣言した直下に、サーバステートからフロントエンドに閉じたスキーマへ変換する ⑫ ViewModel Selector を定義します。 null の 空文字への変換や時間データのフォーマットのような、 サーバステート と View で使う値の差分吸収はこのセレクタ内で行います。

このように、ViewModel レイヤーでは他のレイヤーと依存しないようにドメインロジックを表現しています。 ドメインロジックを Tanstack Query に依存しないことで、今後技術基盤を刷新する場合でも影響を最小限に留めることを狙いとしています。

フロントエンドに閉じた型

依存関係を整えるため、Resource Operation と View から参照するフロントエンドに閉じた型は viewModels 配下に宣言しています。

// src/viewModels/todo/todoSearchParams.ts

export type TodoSearchCondition = {
  sort?: "created_at_asc" | "created_at_desc";
};

ViewModel に関する実装の紹介は以上です。

Tanstack Query 導入の背景とアーキテクチャの狙い

背景:コード品質の課題

Tanstack Query を導入する以前の CLINICS の非同期処理周辺のコードベースではいくつかの課題がありました。

開発体験の課題

非同期処理のためのミニマムな基盤フックを独自に実装していた5ことにより、開発体験の観点で次のような課題がありました。

  • 新しい要件(ポーリング・無限読み込み等)が発生した際に、最初に担当する開発者が都度機能拡張する必要がある
  • テストが実装されていなかったため変更時に品質確認の負担が大きい

学習容易性の課題

上述の基盤フックにドキュメントがなかったため、学習容易性の観点で次のような課題がありました。

  • 基盤フックにドキュメントがなく、新規メンバーの学習コストが高い
  • 様々な実装手法が混在することで、実装時に迷いが生じている

可読性の課題

CLINICS は開発開始から約 7 年以上が経過しています。 その中でもフロントエンドは技術トレンドの変化が早いため、様々なライブラリやパターンを使って実装されています。 特に近年から漸進的に導入した React を使った実装については、明確な設計が確立されていませんでした。

これらの背景からチームで安定したアウトプットを出すことが困難で、可読性の観点で次のような課題がありました。

  • 画面によってフックや関数の粒度、定義場所が違うことでコードリーディングの負荷が高い
  • コードレビュー時のレビュワーの負担が大きい

テスト容易性の課題

多くのドメインロジックがコンポーネントやカスタムフック内に混在していることで、テスト容易性の観点で次のような課題がありました。

  • テストが実装しづらい
  • テストカバレッジが低い

CLINICS はオンライン診療・電子カルテ等の医療機関業務を支える機能を提供する SaaS プラットフォームです。 今後長きに渡って多くの医療機関の方々に CLINICS を利用して頂くためには、コードを読みやすく、変更しやすく、維持しやすい状態に保ち続けることが重要です。

前の章で紹介したアーキテクチャは持続可能な開発を目指して設計しました。 具体的には、開発体験・学習容易性・可読性・テスト容易性を向上することを狙いとしています。

狙い 1:Tanstack Query の導入による開発体験の向上

背景で説明したとおり、Tanstack Query 導入以前は独自実装したフックを使って非同期処理を実装していました。

Tanstack Query を導入したきっかけは、チーム内での雑談の中で、独自実装のフックが使いづらいという声が挙がったことです。 そこで、独自実装のフック自体の質を高めるか、質の高いライブラリを導入するかを議論した結果、次の理由でライブラリを導入する決定をしました。

  • 極力自分たちでコードを書かずに非同期処理・状態管理の実装を実現したい
  • 実装に困ったときにドキュメントを読めば解決する環境にしたい

CLINICS では技術的な背景6から Tanstack Query と SWR が候補に上がりましたが、 select オプションや invalidateQueriesPartial Query Matching 等の機能性と、ドキュメントの充実度合いの観点から Tanstack Query を採用しました。

Tanstack Query を採用したことで、非同期処理のためのコードの記述量が大幅に削減されたほか、データの特性に応じて Query ごとにキャッシュの時間を調整することが可能となり開発体験が大幅に向上しました。

狙い 2:シンプルなレイヤー分割による学習容易性の向上

CLINICS では、事業の拡大にともないコードベースに関わるエンジニアが増え続けています。 実装の進め方はプロジェクトによって最適な形式を選択しています。

  • バックエンドからフロントエンドまで一気通貫で実装
  • 技術領域に分けて分業

このように多くのエンジニアが様々な形で関わる環境では、コードベースの学習容易性を高め、実装からコードレビューの完了までをスムーズに行えることが重要です。

前の章で紹介したアーキテクチャは、レイヤー分割をシンプルにすることでコードベースに慣れるまでの時間を最小限にすることを意識しています。

加えて、実装パターンを定形化することで、コードベースに馴染みがなくても迷いなく実装できるほか、関数の粒度が統一されることで、コードレビューの負荷軽減にも繋がっています。

レイヤー分割の粒度や実装パターンについては、次の記事を参考にさせて頂きました。

ほかにも CLINICS では学習容易性の向上の取り組みとして、新しいライブラリやアーキテクチャを導入した際は勉強会を開催してライブラリの基本的な使い方や頻出の実装パターンに関する知見を共有しています。

狙い 3:ドメインロジックを純粋関数で表現することによる可読性・テスト容易性の向上

アーキテクチャを刷新する以前は、多くのドメインロジックがコンポーネントやカスタムフック内に混在していることで、可読性やテスト容易性に支障をきたしていました。

CLINICS のフロントエンドには、医療システムに関する複雑なドメインロジックが多いため、シンプルでテストしやすいコードベースを作っていくことがとりわけ重要だと考えています。

この課題は、ドメインロジックが集まる傾向にある queries.ts、 mutations.ts の Request Selector や ViewModel Selector の実装を定型化し、純粋関数で表現することにより解決しました。

まとめ

Tanstack Query を使ったフロントエンドアーキテクチャの実例を紹介しました。

  • Resource Operation レイヤーに Tanstack Query の実装を定型化して集約しています。
  • レイヤー分割をシンプルにし、実装を定型化することで、開発組織のスケールに対応しています。
  • useQuery の select オプションを使い、スケーラブルにデータ変換処理を記述しています。
  • データ変換処理にはドメインロジックが集まりやすいため、なるべく小さい粒度の純粋関数で表現することで、可読性・テスト容易性の向上を狙っています。

この記事の内容が Tanstack Query の導入を考えている方の参考になれば幸いです。

さいごに

CLINICS では、機能開発と並行してフロントエンド基盤を改善する取り組みも実施しています。

  • Redux から Tanstack Query への移行
  • UI ライブラリの Mithril から React への移行7
  • デザインシステムの構築とプロダクトへの反映

このような取り組みに興味がある方は次のリンクから是非ご連絡ください。

https://www.medley.jp/jobs/


  1. Overview | TanStack Query Docs

  2. Comparison | React Query vs SWR vs Apollo vs RTK Query vs React Router | TanStack Query Docs

  3. この記事で紹介している ViewModel は View レイヤー専用のモデルを表す概念です。 MVVM アーキテクチャの ViewModel とは異なります。

  4. Tanstack Query におけるデータ変換手法の詳細は React Query Data Transformations | TkDodo's blog で紹介されています。

  5. Tanstack Query 導入以前の非同期処理は useAsync React Hook - useHooks をカスタマイズしたフックを使って実装していました。

  6. CLINICS では 一部の実装箇所で Redux を使用していますが、 RTK Query は今回採用するライブラリの候補から除外しました。これは、現在使用している Redux のバージョンが低く、レガシーな周辺ライブラリも複数使用している背景で Redux Toolkit への移行に相当な工数を必要とするためです。

  7. 2023 年 3 月現在、 CLINICS のフロントエンドの 約 50% は、 Mithril と Redux で構成されています。開発体験の向上のため、 React への完全移行を目指して日々改善を続けています。

株式会社メドレーDeveloper Portal

© 2016 MEDLEY, INC.