🧐

React + Next + React Hook Formで表形式フォームが複数ある画面を作る

2022/08/02に公開

はじめに

お疲れ様です。
@いけふくろうです。

表形式のUI/UXを実装する際に、React Hook FormのuseFieldArrayのAPIを活用しました

表形式を表現するとなったため、有識者の方と会話した際に、列を追加する機能がないのであれば、useFieldArrayを使ってみるのはどうか?とご助言いただいたので、トライしてみました!

機能概要

  • スコアテーブルA、スコアテーブルBそれぞれにおいて、教科に対するスコアを表形式で表現する
    • つまり、2段のテーブルを表現したい
  • ドロップダウンリストで選択された教科に合致する行の末尾に新規の行を挿入したい
  • 入力ボックスの値に対するバリデーション及び登録処理

環境

  • React v18.2.0
  • Next v12.2.3
  • React Hook Form v7.34.0
  • TailwindCSS v3.1.6

実装方針

  • 詳細については、こちらでは省略いたしますが、コンポーネントの設計方針として、コンテナ・プレゼンテーションパターンを採用している
  • React Hook Formに依存する部分を作ることになるが、その部分は、コンテナ層で実装し、プレゼンテーションには、propsで情報を渡すようにする

完成イメージ

  • なお、スタイリングは、最低限としています
  • 実装内容などの説明を記載する前に、実装した動画を先に掲載いたします

実装内容

※コードは全量ではなく、抜粋しております

表形式コンポーネントのディレクトリ構成

├── components ・・・ コンポーネントをまとめるディレクトリ
│   ├── TablarForm  ・・・ 表形式コンポーネントのディレクトリ
│   │   ├── TablarForm.tsx ・・・ Container層として、ロジックを定義するファイル
│   │   └── Presenter.tsx ・・・ Container層から表示に必要なpropsを受領し、画面表示するためのファイル
│   │   └── index.ts ・・・ コンポーネントを出力するファイル
│   │   └── type.ts ・・・ 表形式コンポーネントの範囲で利用する型定義ファイル
│   │   └── ScoreItem  ・・・ 表形式のUIを表示するコンポーネントディレクトリ
│   │       ├── Presenter.tsx ・・・ TablarForm/Presenter.tsxから表示に必要なpropsを受領し、画面表示するためのファイル
│   │       └── index.ts ・・・ コンポーネントを出力するファイル

TablarForm.tsx: ロジック部分の実装

共通の型定義

src/type/domain/constants/Subject.ts
const Subject = {
  JAPANESE: "国語",
  MATH: "数学",
  ENGLISH: "英語",
} as const;

export type Subject = typeof Subject[keyof typeof Subject];

export type SubjectType = {
  id: number;
  name: Subject;
};

export const SubjectTypes: SubjectType[] = [
  {
    id: 1,
    name: "国語",
  },
  {
    id: 2,
    name: "数学",
  },
  {
    id: 3,
    name: "英語",
  },
];
  • typeof Subject[keyof typeof Subject]とすることで、keyofでオブジェクトのプロパティを取得できるので、そのプロパティにセットされている値を取得し、"国語" | "数学" | "英語"というユニオン型を作っています
src/type/domain/ScoreTable/ScoreTable.ts
import { Subject } from "../constants/Subject";

export type Score = {
  id: number | null;
  yearMonth: string;
  score: number | null;
};

export type ScoreTable = {
  isSelected: boolean;
  subject: {
    id: number;
    name: Subject;
  };
  scores: Score[];
};

コンテナ層の実装部分

src/components/TablarForm/TablarForm.tsx
interface Props {
  scoreTableHeader: string[];
  scroreTableA: ScoreTable[];
  scroreTableB: ScoreTable[];
}

export interface ScoreFormData {
  scoreTableAItems: ScoreTable[];
  scoreTableBItems: ScoreTable[];
}

const TablarForm: React.FC<Props> = (props) => {
  const { scoreTableHeader, scroreTableA, scroreTableB } = props;

  const {
    register,
    handleSubmit,
    control,
    reset,
    formState: { errors },
  } = useForm<ScoreFormData>({
    defaultValues: {
      scoreTableAItems: scroreTableA,
      scoreTableBItems: scroreTableB,
    },
    mode: "onSubmit", // registerをいつ検証するかの指定(デフォルトは、onSubmit)
    reValidateMode: "onSubmit", // エラー後の再検証イベントの指定(デフォルトは、onChange)
  });

  // スコアテーブルAのuseFieldArray構築
  const { fields: scoreTableAItems, insert: insertTableA } = useFieldArray({
    control,
    name: FieldArrayName.SCORE_TABLE_A,
  });

  // スコアテーブルBのuseFieldArray構築
  const { fields: scoreTableBItems, insert: insertTableB } = useFieldArray({
    control,
    name: FieldArrayName.SCORE_TABLE_B,
  });
  
  ...
  
    /**
   * 新規入力行追加処理
   * @param insert useFieldArrayのinsertメソッド
   * @param items useFieldArrayコントール配下のデータ
   * @param subject 行追加対象の教科
   */
  const handleClickInsertRow = (
    insert:
      | UseFieldArrayInsert<ScoreFormData, "scoreTableAItems">
      | UseFieldArrayInsert<ScoreFormData, "scoreTableBItems">,
    items: FieldArrayWithId<ScoreFormData, FieldArrayName, ItemKey>[],
    subject: SubjectType,
  ) => {
    const foundSubjectLastIndex = items
      .map((item) => item.subject.id)
      .lastIndexOf(subject.id);

    // 見つからない場合には、システム異常のため、スローさせる
    if (foundSubjectLastIndex === -1) {
      throw new Error(`Not Found ${subject}`);
    }

    // 引数に指定された教科の末尾に新規の入力行を追加する
    insert(
      foundSubjectLastIndex + 1,
      buildInitialData(subject, scoreTableHeader),
    );
  };

  /**
   * スコア登録処理
   * @param data 入力データ
   */
  const handleSubmitScoreRegistration: SubmitHandler<ScoreFormData> = (
    data: ScoreFormData,
  ) => {
    console.log("submit data:", data);
    reset();
  };

  return (
    <Presenter
      scoreTableHeader={scoreTableHeader}
      scoreTableAProps={{
        selectedSubject: selectedTableASubject,
        onChangeSubject: handleChangeTableASubject,
        items: scoreTableAItems,
      }}
      scoreTableBProps={{
        selectedSubject: selectedTableBSubject,
        onChangeSubject: handleChangeTableBSubject,
        items: scoreTableBItems,
      }}
      register={register}
      insertTableA={insertTableA}
      insertTableB={insertTableB}
      onClickInsertRow={handleClickInsertRow}
      onSubmit={handleSubmit}
      onSubmitScoreRegistration={handleSubmitScoreRegistration}
      errors={errors}
    />
  );
};

export default TablarForm;

useFormの利用

  • ScoreFormDataの型を指定し、Propsで受領した値をdefaultValuesとして、セット
  • コメントも記載していますが、submit後の再検証のタイミングをデフォルトのonChageイベントではなく、reValidateModeで、onSubmitを明示的に指定しています
  • formState: { errors }で、 errorsに、submit時のバリデーションエラーが、FieldErrors<ScoreFormData>として、格納されるので、エラー表示用に使用できます

useFieldArrayの利用

  • コントロール対象の配列に名前を付与して、セットしています
  • 新規行の挿入機能で、insertメソッドを使うため、別名を付与しています

なお、表形式コンポーネント内で使用する型定義は下記です。

src/components/TablarForm/type.ts
import { SubjectType } from "@/type/domain/constants/Subject";
import { FieldArrayWithId, UseFieldArrayInsert } from "react-hook-form";
import { ScoreFormData } from "./TablarForm";

export const FieldArrayName = {
  SCORE_TABLE_A: "scoreTableAItems",
  SCORE_TABLE_B: "scoreTableBItems",
} as const;

export type FieldArrayName = typeof FieldArrayName[keyof typeof FieldArrayName];
export type ItemKey = "id";

export interface ScoreTableProps {
  selectedSubject: SubjectType;
  onChangeSubject: (event: React.ChangeEvent<HTMLSelectElement>) => void;
  items: FieldArrayWithId<ScoreFormData, FieldArrayName, ItemKey>[];
}

新規入力行追加処理

    const foundSubjectLastIndex = items
      .map((item) => item.subject.id)
      .lastIndexOf(subject.id);
  • 配列内にある教科idをmapを使って、取得し、その中に引数で指定された教科idの最後のindexを抽出しています
  • バグのリスクが上がるので、極力、作成する変数(特に、letは避けられるかよく考えるようにしている)は少なくなるように意識し、記述のコードのようにしています
    ※可読性の考慮も必要
    // 引数に指定された教科の末尾に新規の入力行を追加する
    insert(
      foundSubjectLastIndex + 1,
      buildInitialData(subject, scoreTableHeader),
    );
  • useFieldArrayから提供されているinsertメソッドを使うと、どこに追加するかを明示的に指定すれば、行を挿入できます
  • buildInitialDataは、プライベート関数なので、省略していますが、初期値として、セットする値を渡しています

TablarForm.tsx / Presenter.tsx: プレゼンテーション部分の実装

src/components/TablarForm/Presenter.tsx
interface Props {
  scoreTableHeader: string[];
  scoreTableAProps: ScoreTableProps;
  scoreTableBProps: ScoreTableProps;
  register: UseFormRegister<ScoreFormData>;
  insertTableA: UseFieldArrayInsert<ScoreFormData, "scoreTableAItems">;
  insertTableB: UseFieldArrayInsert<ScoreFormData, "scoreTableBItems">;
  onClickInsertRow: (
    insert:
      | UseFieldArrayInsert<ScoreFormData, "scoreTableAItems">
      | UseFieldArrayInsert<ScoreFormData, "scoreTableBItems">,
    items: FieldArrayWithId<ScoreFormData, FieldArrayName, ItemKey>[],
    subject: SubjectType,
  ) => void;
  onSubmit: UseFormHandleSubmit<ScoreFormData>;
  onSubmitScoreRegistration: (data: ScoreFormData) => void;
  errors: FieldErrors<ScoreFormData>;
}

const Presenter: React.FC<Props> = (props) => {
  const {
    scoreTableHeader,
    scoreTableAProps,
    scoreTableBProps,
    register,
    insertTableA,
    insertTableB,
    onClickInsertRow,
    onSubmit,
    onSubmitScoreRegistration,
    errors,
  } = props;

  return (
    <>
      <form onSubmit={onSubmit(onSubmitScoreRegistration)}>
        <h1 className={"mb-2"}>スコアテーブルA</h1>
        <ScoreItem
          fieldArrayName={FieldArrayName.SCORE_TABLE_A}
          scoreTableHeader={scoreTableHeader}
          register={register}
          items={scoreTableAProps.items}
          selectedSubject={scoreTableAProps.selectedSubject}
          onChangeSubject={scoreTableAProps.onChangeSubject}
          insert={insertTableA}
          onClickInsertRow={onClickInsertRow}
          errors={errors}
        />

        <h1 className={"mb-2"}>スコアテーブルb</h1>
        <ScoreItem
          fieldArrayName={FieldArrayName.SCORE_TABLE_B}
          scoreTableHeader={scoreTableHeader}
          register={register}
          items={scoreTableBProps.items}
          selectedSubject={scoreTableBProps.selectedSubject}
          onChangeSubject={scoreTableBProps.onChangeSubject}
          insert={insertTableB}
          onClickInsertRow={onClickInsertRow}
          errors={errors}
        />

        <div className={"mt-4"}>
          <button
            type={"submit"}
            className={
              "px-2 py-1 text-indigo-500 border border-indigo-500 font-semibold rounded hover:bg-indigo-100 text-lg"
            }
          >
            登録
          </button>
        </div>
      </form>
    </>
  );
};

export default Presenter;
  • 画面表示の責務を負うため、React Hook Formに関係する内容もpropsで受領するようにしています
    • propsの数が多い部分や一部冗長な部分があるので、今後の改善ポイントでもあるかなと思っています
  • 後述するScoreItemコンポーネントへバケツリレーをして、実際のテーブル部分のUIを構築するようにしています
  • <form onSubmit={onSubmit(onSubmitScoreRegistration)}>とすることで、submit時に、onSubmitScoreRegistrationが実行されて、コンテナ層で処理ができるようになっています

ScoreItem / Presenter.tsx: テーブル表示の実装

src/components/TablarForm/ScoreItem/Presenter.tsx
interface Props {
  fieldArrayName: FieldArrayName;
  scoreTableHeader: string[];
  register: UseFormRegister<ScoreFormData>;
  items: FieldArrayWithId<ScoreFormData, FieldArrayName, ItemKey>[];
  selectedSubject: SubjectType;
  onChangeSubject: (event: React.ChangeEvent<HTMLSelectElement>) => void;
  insert:
    | UseFieldArrayInsert<ScoreFormData, "scoreTableAItems">
    | UseFieldArrayInsert<ScoreFormData, "scoreTableBItems">;
  onClickInsertRow: (
    insert:
      | UseFieldArrayInsert<ScoreFormData, "scoreTableAItems">
      | UseFieldArrayInsert<ScoreFormData, "scoreTableBItems">,
    items: FieldArrayWithId<ScoreFormData, FieldArrayName, ItemKey>[],
    subject: SubjectType,
  ) => void;
  errors: FieldErrors<ScoreFormData>;
}

const Presenter: React.FC<Props> = (props) => {
  const {
    fieldArrayName,
    scoreTableHeader,
    register,
    items,
    selectedSubject,
    onChangeSubject,
    insert,
    onClickInsertRow,
    errors,
  } = props;

  return (
    <>
      <table className={"w-full"}>
        <thead
          className={
            "px-2 first:pl-0 last:pr-0 text-xs text-left text-gray-800 w-3/12 font-bold"
          }
        >
          <tr className={"align-top table-row"}>
            <td className={"px-2 text-sm text-gray-600"}>
              <p>選択</p>
            </td>
            <td className={"px-1 w-10 text-center text-sm text-gray-600"}>
              <p>教科</p>
            </td>
            {scoreTableHeader.map((yearMonth, index) => (
              <td key={index} className={"px-1 text-sm text-gray-600"}>
                {yearMonth}
              </td>
            ))}
          </tr>
        </thead>

        <tbody>
          {items.map((item, index) => (
            <tr key={index} className={"align-top table-row"}>
              <td className={"px-4 text-center"}>
                <input
                  type={"checkbox"}
                  defaultChecked={item.isSelected}
                  {...register(
                    `${fieldArrayName}.${index}.isSelected` as const,
                  )}
                />
              </td>
              <td className={"px-1 text-center text-sm text-gray-600"}>
                <p>{item.subject.name}</p>
              </td>
              {item.scores.map((score, idx) => (
                <td
                  key={score.yearMonth}
                  className={"px-1 text-sm text-gray-600"}
                >
                  <div>
                    <input
                      type={"text"}
                      defaultValue={score.score || undefined}
                      {...register(
                        `${fieldArrayName}.${index}.scores.${idx}.score` as const,
                        {
                          min: {
                            value: 0,
                            message: "0〜100の数値を入力してください",
                          },
                          max: {
                            value: 100,
                            message: "0〜100の数値を入力してください",
                          },
                          pattern: {
                            value: /^[0-9]+$/,
                            message: "半角数値で入力してください",
                          },
                        },
                      )}
                      className={`border-gray-200 focus:border-gray-500 focus:outline-none appearance-none block py-3 px-4 w-full leading-tight text-gray-700 focus:bg-white rounded border`}
                    />

                    {/* NOTE: 配列内のプロパティ名を動的に指定しているため、ブラケット記法([])を使って、errorsのプロパティにアクセスしている */}
                    {errors[fieldArrayName]?.[index]?.scores?.[idx]?.score && (
                      <div className={"mt-1 text-xs text-red-500"}>
                        {
                          errors[fieldArrayName]?.[index]?.scores?.[idx]?.score
                            ?.message
                        }
                      </div>
                    )}
                  </div>
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>

      <div className={`flex py-4`}>
        <DropDown<SubjectType>
          value={selectedSubject}
          onSelect={onChangeSubject}
          selectOptions={SubjectTypes.map((subject) => ({
            label: subject.name,
            value: subject,
          }))}
        />

        <input
          type={"button"}
          value="行追加"
          onClick={() => onClickInsertRow(insert, items, selectedSubject)}
          className={
            "ml-4 px-2 py-1 bg-blue-400 text-white font-semibold rounded hover:bg-blue-500"
          }
        />
      </div>
    </>
  );
};

export default Presenter;
  • useFieldArray及びuseFormコントロール配下の情報をpropsで受け取って、バリデーションを実装しています
  • ヘルパーコンポーネントであるDropDownのコードは、省略いたしますが、ドロップダウンリストで、教科を選択し、ボタン押下にて、コンテナ層の新規入力行追加処理を実行するようにしています
errors[fieldArrayName]?.[index]?.scores?.[idx]?.score?.message
  • エラーがあった場合には、formState: { errors }の部分で、下記のようなデータ構造で、値が入っているので、それを取り出して、画面にラベル表示しています

おわりに

  • React Hook Formを表形式で活用することは、今回が初めてだったのですが、提供されているAPIを公式のサンプルを確認して、まずは、最小コードで書いて、動いたら、コンテナ層での制御やコンポーネント化するステップを踏むことで、一旦完走することができました!!
  • 他の画面でも使用するならば、forminput部分をコンポーネント化することで、汎用的になるかなと感じています
  • React Hook Formは、最高ですね!!

以上です。
本記事が何かの一助になれば幸いです。

Discussion