orvalを使ったWebフロントエンド改善

昨年10月にクラスター社に加わったmt_blue81です。 Webフロントエンドをメインに施策に加わりつつ、開発環境の改善などにも取り組んでいます。 今回はWebフロントエンドの状態管理まわりの改善についてご紹介します。

clusterのweb画面

clusterはマルチプラットフォームですが、webからも様々な操作が行えるよう機能が搭載されています。

cluster.mu

大きくは以下のようなスタックでつくられています。

  • UIはReact + Material-UI + styled-component
  • ルーティングはreact-router
  • 状態管理はRedux
  • バックエンドとのやり取りはOpenAPIによって定義

Reduxの状態管理はre-ducksパターンで構築されており、バックエンドとのやり取りもこのoperation層で実現されていました。re-ducksパターンは対象の状態と、その状態を操作するためのoperationを近くに配置できるため、関心のある部分がまとまるというメリットがあります。(clusterではこのまとまりをmoduleと呼んでいます)

チームのなかでもドキュメントを整備したり、module作成時にテンプレートからひな形を生成するようにして実装コストを下げる工夫をしており、実際自分がチームに加わったときにも、コードが整理されて理解しやすい状態が保たれていました。

脱Redux

とはいえバックエンドからのデータを扱う上で、以下のようにそれぞれで似たようなコードを毎回書くという課題感がありました。

  • operation: APIを呼び出してデータactionでdispatchする
  • reducer: storeにデータを格納する
  • selector: storeに格納されたデータを取得する

またこちらの記事などで言及があるように、Reactにおける状態にはいくつか種類があり、すべてを同じように管理する必要はないのではという点も社内で議論しました。

zenn.dev

現行のReduxでの処理がキャッシュ的な側面とselectorによるUI用データ整形にフォーカスしており、その部分は後述のdata fetchingライブラリに任せるほうが良いだろうという結論となりました。

そこで、API定義からできるだけコードを自動生成させつつ、通信データを取り扱うだけの層で分離できないかと考え、いくつかのツールを検討の上でorvalを利用することにしました。*1

orval.dev

選定するにあたって特に意識したのは以下の点です。

  • 出力コードが構造化されており読みやすい
  • SWRやreact-queryなどのdata fetchingライブラリによるhooks I/Fの提供
  • ドキュメンテーションがしっかりしている
  • メンテナンスされている

こちらのブログでもreact-queryを利用できるメリットが書かれていて参考になります。 また、orvalによるAPIデータ管理を採用することで、Reduxはそれ以外のグローバル状態を管理するのみで、シンプルになります。

zenn.dev

実際の使い方

clusterでは現在以下のような流れを定めて、徐々に移行していっています。

1. OpenAPI定義を定期的に取り込んで、orvalによるhooksコードを生成しておく

キャッシュ層にはreact-query、APIクライアントにはaxiosを利用

2. module単位で専用のhooksを作成する

以下サンプルコードになります

イベントを取得するhooks (useEvent.ts)

import {
  getGetEventQueryKey,
  useGetEvent,
} from 'orvalからの出力先';
import { mapEventFromResponse} from './mapper';
    
const useEvent = (id) => {
  return useGetEvent(id, {
    query: {
      select: (response) => mapEventFromResponse(response)
    },
  });
};

export const invalidateEvent = (id) =>
  queryClient.invalidateQueries({
    queryKey: getGetEventQueryKey(id),
  });

export default useEvent; 

イベントを更新するhooks(useUpdateEvent.ts)

import {
  usePutEvent,
} from 'orvalからの出力先';
    
const useUpdateEvent = () => {
  const { mutate, ...result } = usePutEvent();

  return {
    updateEvent: useCallback(
      (id, data) =>
        mutate(
          { id, data },
          {
            onSuccess: () => {
              invalidateEvent(id);
            },
           },
        ),
      [dispatch, mutate],
    ),
    ...result,
  };
};

export default useUpdateEvent;

3. Reactコンポーネントでの呼び出しを置き換えていく

作成したhooksを利用するコード

const eventResult = useEvent(id);
const { updateEvent, ...updateEventResult} = useUpdateEvent();

switch(eventResult.status) {
  case 'loading':
    return <Spinner/>;
  case 'error':
    return <Error>{eventResult.error}</Error>;
  case 'success:
    return (
      <Event
        event={eventResult.data}
        onUpdate={updateEvent}
        isUpdateing={updateEventResult.isLoading}
      />
    );
}

さらに以下のような内容をドキュメントにまとめながら運用しています。

  • 作成するhooksのI/Fはreact-queryに合わせたResult型を踏襲する
  • 複雑なAPI呼び出しが必要な場合は、QueryFunctionと呼ばれる通信部分だけを直接利用して組み合わせることを許容する
  • query用のhooksはinvalidation(キャッシュ破棄)のためのI/Fを公開して、mutation時に利用する
  • 既存のReduxで利用していたmapper*2を流用して、アプリケーション内のデータ構造との変換を担保する

まとめ

昨年(2022年)の11月ごろに構想を始めてから半年ほどですが、60個ほどあるmoduleの2,3割が置き換えられています。 また、新規に作成される部分については、orval hooksを採用してつくるようになっています。 サービス自体の施策も走っている中での改善なので、徐々にですが着実に進めていくようにしています。*3

また、Reduxで書いていた部分を置き換えていると、関連するoperationとselectorが離れたところで利用されていたりして、暗黙の依存のようなものがあるのが改めてわかりました。これらを必要な場所に宣言的に書いていけるというのは、可読性の観点からもよいリファクタリングになっていると感じています。 それ以外にもreact-queryを利用していることで、通信のリトライやキャッシュをあまり意識することなくサポートできており、ユーザ体験の向上にもなりそうです。

以上、Webフロントエンドの改善作業の一端をレポートしました。

クラスター社ではこういった改善もサービスを加速させるための要件と捉えられており、メンバー同士で議論したりdesign docを書くなど、積極的に活動しています。

興味がある方はぜひ下記からアクセスしてみてください。

recruit.cluster.mu

*1:クラスター社では意思決定する際にdesign docというドキュメントを残す文化があるのですが、それを見返すとaspida, openapi-generator, rtk-query-codegen-openapi, openapi-typescript-codegenあたりを検討していました

*2:バックエンドとの通信データとReactアプリケーション側で利用したいデータの形を整える部分

*3:メインで手伝ってくれるメンバーも増えたので絶賛加速中です