はじめに

本記事では省力化コンポーネントを使用したReact開発の事例を紹介します。特に、省力化コンポーネントの使用が難しい画面の実装方法を2つ紹介します。この記事を参考にすることで、省力化コンポーネントの使用範囲をより広げることができるようになると思います。

想定読者

Reactでフロントエンドを実装するエンジニア

省力化コンポーネント

React開発の生産性とメンテナンス性の向上を目的としたコンポーネントです。
例えば10項目が並んだ入力画面であればコード量を約30%に省力化できます。
2024年12月にライブラリを公開しました。

関連リンク

プロジェクト:クラウド版医事一体型電子カルテ開発

Reactでフロントエンドを実装しています。UIライブラリはAnt Designを使用しています。
単項目バリデーションで十分な入力画面が多数ある見込みであったため、省力化コンポーネントを導入しました。
省力化コンポーネントを使用して、実際に830個の入力項目と463個のAPIイベントを省力化することができました。
ドキュメント整備やライブラリ公開が未実施の段階で導入し、トライアルの役割も担っていました。

関連リンク

省力化コンポーネントの導入効果が得られにくい画面の特徴

省力化コンポーネントには導入指標があります。導入効果が高い特徴を持つ画面ではそのまま利用すればよいのですが、導入効果が得られにくい特徴を持つ画面では、本記事で紹介するような工夫が必要です。

導入効果が得られにくい画面の特徴は以下の通りです。

導入効果が得られにくい画面の特徴

  1. 入力系の画面項目が動的に増減する
  2. 参照系の画面項目が多い
  3. ボタン押下時以外のイベント処理が多い
  4. 1つの画面項目に対してデザインが複数ある

1の特徴を持つ画面は実装が困難または複雑になり得ます。2の特徴を持つ画面は実装自体には課題がないものの、省力化のターゲットではないため得られる効果が少ないです。3、4の特徴を持つ画面は省力化コンポーネントの拡張が必要になるため、費用対効果を考慮する必要があります。

私たちのプロジェクトでは省力化できるメリットを重要と捉え、導入効果が得られにくい特徴を持つ画面でも省力化コンポーネントを積極的に使用する方針を採用しました。そこで、1、3の特徴を持つ画面の実装方法を紹介します。4の特徴を持つ画面の実装方法については、ドキュメントのTips/Itemデザインをカスタムする方法を参照してください。

入力系の画面項目が動的に増減する画面の実装

要件

テーブル形式のデータに対してCRUD操作が可能な入力画面を実装する必要がありました。この画面では、Ant Designの公式ドキュメントにあるEditable Rowsのように、テーブルの各レコードを直接編集することが求められました。

課題

省力化コンポーネントではカスタムフックを使用して入力項目を定義します。カスタムフックはループ内で使用することができないため、各レコードに対応した入力項目を作成できません。

実装方法

この画面では、省力化コンポーネントが提供している入力項目の機能を使用せず、APIの機能のみを使用しました。
入力項目の機能は以下のように独自に実装しました。

  • バリデーションイベントと画面コンポーネントは、Editable Rowsを参考にしてAnt DesignのFormコンポーネントとTableコンポーネントで作成しました。
  • Ant Designのプロパティに合うように、入力値はstateを設けて管理しました。

APIの機能は省力化コンポーネントを使用し、stateの値をAPIのリクエストおよびレスポンスと同期しました。
次のコード例では、loadEventで取得したレスポンスのデータをstateにセットし、stateのデータをupdateEventで送るリクエストにセットしています。

// 入力値のstate
const [dataList, setDataList] = useState<Data[]>([]);

// レスポンスのデータをstateにセット
useEffect(() => {
  if (view.loadEvent.isSuccess) {
    setDataList(view.loadEvent.response.data.dataList);
  }
}, [view.loadEvent.isSuccess, view.loadEvent.response.data.dataList]);
// stateのデータをリクエストにセット
view.updateEvent.setRequest({
  data: {
    dataList,
  },
});

モーダルを使用した別の回避方法

上記の方針で実装する前に、各レコードの編集ボタンが押された際にモーダルを表示して編集を行うという仕様を提案しました。モーダルで一定の入力項目を表示することで、入力項目の動的な増減を防ぐことができます。しかし、テーブルのレコードを直接編集したいという要望が強く、この画面ではこの方法での実装は見送られました。

ボタン押下時以外のイベント処理が多い画面の実装

要件

特定の順序で複数のAPIを呼び出し、他のAPIのレスポンスをもとにAPIのリクエストを設定する必要がありました。この画面では、初期表示のタイミングで3つのAPIを順番に呼び出すことが求められました。APIのリクエストは直前のAPIのレスポンスに依存していました。

課題

省力化コンポーネントではロードイベントと呼ばれる初期表示のタイミングでAPIを呼び出すイベントと、ボタンクリックイベントと呼ばれるボタン押下のタイミングでAPIを呼び出すイベントを定義できます。これらのイベントには、複数のAPIを呼び出したり呼び出しの順序を制御したりする機能がありません。
ただし、順序を制御することはできないものの、初期表示のタイミングで複数のロードイベントを走らせることは可能です。

実装方法

この画面では、1つ目はロードイベントを使用し、それ以降の2つはボタンクリックイベントを使用して順番にAPIを呼び出しました。リクエストはstateを設けて管理しました。
ボタンクリックイベントはボタンコンポーネントとセットで使用します。ボタンクリックイベントを使用して、ボタン押下ではないタイミングでAPIを呼び出すために、ボタンコンポーネントを以下のように工夫しました。

  • refプロパティを受け取れるようにボタンコンポーネントを拡張しました。ボタンコンポーネントに対してrefオブジェクトを設定し、currentプロパティからDOMノードにアクセスすることで、clickメソッドでボタン押下をシミュレートしてAPIを呼び出すことができます。
  • ボタンコンポーネントにはonAfterApiCallSuccessというプロパティがあります。このプロパティには成功のレスポンスを受け取ったタイミングで呼び出すメソッドを設定できます。メソッドの引数にはAPIイベントをとります。ロードイベントの時はuseEffect、ボタンクリックイベントの時はonAfterApiCallSuccessで次のAPIに対してリクエストの設定と呼び出しの制御を行いました。
  • 画面項目として存在しないボタンコンポーネントはCSSで非表示にしました。

次のコード例では、forwardRefを使用してボタンコンポーネントがrefプロパティを受け取れるようにしています。

// AxEventCtrl.tsx

export const AxQueryButton = forwardRef(
  <TApiResponse = unknown,>(props: AxQueryButtonProps<TApiResponse>, ref) => {

// 省略

<Button
  className={getClassName(props, "button")}
  type={props.type}
  loading={event.isLoading}
  onClick={() => {
    onClick();
  }}
  ref={ref}
  {...antdProps}
>
  {props.children}
</Button>

// 省略

  },
);
AxQueryButton.displayName = "AxQueryButton";

また、stateは非同期で値を更新するため、リクエストの値を管理しただけでは古い値でAPIを呼び出してしまう可能性があります。同じstateで一緒にflagを管理し、flagがtrueの時にAPIを呼び出すことで、リクエストの値が更新されたことを保証しました。
次のコード例では、callApi1、callApi2、callApi3の3つのイベントを使用してAPIを呼び出しています。

// sample.view.ts

type SampleView = CsView & {
  // callApi1はロードイベント
  callApi1: CsRqQueryLoadEvent<Api1Response>;
  // callApi2はボタンクリックイベント
  callApi2: CsRqQueryButtonClickEvent<Api2Response>;
  // callApi3はボタンクリックイベント
  callApi3: CsRqQueryButtonClickEvent<Api3Response>;
};

export const useSampleView = (api1Params: Api1Params, api2Params: Api2Params, api3Params: Api3Params): SampleView => {
  return useCsView({
    callApi1: useCsRqQueryLoadEvent(useApi1(api1Params, { query: { refetchOnWindowFocus: false } })),
    callApi2: useCsRqQueryButtonClickEvent(useApi2(api2Params, { query: { refetchOnWindowFocus: false } })),
    callApi3: useCsRqQueryButtonClickEvent(useApi3(api3Params, { query: { refetchOnWindowFocus: false } })),
  });
};
import styles from "./sample.module.css";
import { useSampleView } from "./sample.view";

// 省略

// リクエストの値を制御するためにstateを定義
// パラメータが更新されたことを保証するためにflagを用意する
const [api2, setApi2] = useState<{ params: Api2Params; flag: boolean }>({ params: {}, flag: false });
const [api3, setApi3] = useState<{ params: Api3Params; flag: boolean }>({ params: {}, flag: false });

// 省力化コンポーネントのカスタムフックで画面を定義
const view = useSampleView(api1Params, api2.params, api3.params);

// ボタンコンポーネントを制御するrefの定義
const callApi2Ref = useRef<HTMLButtonElement>(null);
const callApi3Ref = useRef<HTMLButtonElement>(null);

// view.callApi1.responseを監視して1つ目のAPIがレスポンスを返したことを検知する
useEffect(() => {
  if (view.callApi1.response === undefined) {
    return;
  }
  // callApi1のレスポンスをapi2.paramsに設定してapi2.flagをtrueにする
  setApi2({
    params: {
      sample: view.callApi1.response.data.sample,
    },
    flag: true,
  });
}, [view.callApi1.response]);

// flagがtrueになったらAPIを呼び出す
useEffect(() => {
  if (api2.flag === false) {
    return;
  }
  callApi2Ref.current?.click();
  setApi2((api2) => {
    return { ...api2, flag: false };
  });
}, [api2.flag]);
useEffect(() => {
  if (api3.flag === false) {
    return;
  }
  callApi3Ref.current?.click();
  setApi3((api3) => {
    return { ...api3, flag: false };
  });
}, [api3.flag]);

// 省略

{/* callApi2用のボタンコンポーネント */}
<AxQueryButton
  event={view.callApi2}
  ref={callApi2Ref}
  addClassNames={[styles["hidden-button"]]}
  // 2つ目のAPIが成功のレスポンスを返した際に呼ばれる
  onAfterApiCallSuccess={(event) => {
    // callApi2のレスポンスをapi3.paramsに設定してapi3.flagをtrueにする
    setApi3({
      params: {
        sample: event.response?.data.sample,
      },
      flag: true,
    });
  }}
/>
{/* callApi3用のボタンコンポーネント */}
<AxQueryButton event={view.callApi3} ref={callApi3Ref} addClassNames={[styles["hidden-button"]]} />
/* sample.module.css */

.hidden-button:global(.button-area) {
  display: none;
}

callApi1はロードイベントであるため、初期表示のタイミングで自動的に1つ目のAPIが呼び出されます。callApi1で呼び出したAPIのレスポンスはview.callApi1.responseに格納されます。1つ目のAPIがレスポンスを返したことを検知するために、useEffectでview.callApi1.responseを監視します。
view.callApi1.responseを監視しているuseEffect内で、view.callApi1.responseをもとに2つ目のAPIのリクエストであるapi2.paramsを設定します。同時にapi2.flagをtrueに設定します。api2を監視しているuseEffect内で、api2.flagがtrueの時にcallApi2に紐づくボタンコンポーネントをクリックして2つ目のAPIを呼び出します。
callApi2に紐づくボタンコンポーネントのonAfterApiCallSuccess内で、2つ目のAPIのレスポンスであるevent.responseをもとに3つ目のAPIのリクエストであるapi3.paramsを設定します。同時にapi3.flagをtrueに設定します。api3を監視しているuseEffect内で、api3.flagがtrueの時にcallApi3に紐づくボタンコンポーネントをクリックして3つ目のAPIを呼び出します。

将来的に望まれる拡張

上記の方針は、現在提供されている機能を活用して拡張コストを抑えるための工夫であり、クリーンな拡張ではありません。将来的にはAPIイベントを抽象化したりAPIイベントを受け取れるコンポーネントを増やしたりする必要があります。

おわりに

本記事では省力化コンポーネントの使用が難しい画面の実装方法を紹介しました。皆さんも省力化コンポーネントを使用してReact開発を省力化し、ぜひ生産性やメンテナンス性を向上させてみてください。