🧐
React + Next + React Hook Formで表形式フォームが複数ある画面を作る
はじめに
お疲れ様です。
@いけふくろうです。
表形式の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を公式のサンプルを確認して、まずは、最小コードで書いて、動いたら、コンテナ層での制御やコンポーネント化するステップを踏むことで、一旦完走することができました!!
- 他の画面でも使用するならば、
form
、input
部分をコンポーネント化することで、汎用的になるかなと感じています - React Hook Formは、最高ですね!!
以上です。
本記事が何かの一助になれば幸いです。
Discussion