何故この記事を書こうと思ったのか
前回執筆した記事の続きになります。
前回記事は、キャッチアップ的な点が多かったので、今回記事のコードをベースにして、コンポーネントを使用、リファクタリングの実施などを行っていきたいと思います。
また、この記事の続編も更新しています。
参照までに前回記事のコード
- 内部ロジックをわかりやすくするため、.mapでのオブジェクト配列からの繰り返し表示は行わない。(リファクタリング課題として別記事に掲載する予定)
- フォーム管理の内容とバリデーションロジック, utils関数などは全てApp.tsx 1ファイルに収める
スタイル、ロジック、などとまぁ非常〜に混在していて見づらいですよね....
ここをリファクタリングしていきます。
import dayjs from "dayjs";
import { SubmitHandler, useForm } from "react-hook-form";
type FormValues = {
fullName: string; // フルネーム(入力必須)
handleName: string; // ハンドルネーム(任意入力)
age: number; // 年齢(数値)
birthDate: string; // 生年月日(YYYY/MM/DD)
gender: string; // 性別(ラジオボタン、選択必須)
interestedIn: string; // 興味のあるジャンル(ラジオボタン)
otherInterest: string; // 興味のあるジャンル("その他"を選択した場合)
hobby: string[]; // 趣味・特技(チェックボックス、任意)
contactMethod: string[]; // 連絡方法(チェックボックス、選択必須)
likeColor: string[]; // 好きな色(チェックボックス、2個まで選択可能)
prefecture: string; // 都道府県(セレクトボックス、任意)
works: string; // 職業(セレクトボックス、選択必須)
profileImage: FileList | null; // プロフィール写真(1ファイル必須)
coverImage: FileList | null; // カバー写真(任意)
otherImage: FileList | null; // その他の写真(最大3ファイル、任意)
};
function App() {
const defaultValues: FormValues = {
fullName: "",
handleName: "",
age: 20,
birthDate: "",
gender: "0",
interestedIn: "",
otherInterest: "",
hobby: [],
contactMethod: [],
likeColor: [],
prefecture: "",
works: "",
profileImage: null,
coverImage: null,
otherImage: null,
};
const {
register,
handleSubmit,
formState: { errors },
watch,
reset,
} = useForm<FormValues>({ defaultValues });
const selectedInterest = watch("interestedIn"); //「興味のあるジャンル」で
const selectedInterestIsOther = selectedInterest === "other"; //「その他」を選んでいることを監視する
const onSubmit: SubmitHandler<FormValues> = (data) => {
alert("クリックしたよ");
console.log(data);
};
return (
<main className="bg-gray-400 h-screen">
<div className="flex flex-col gap-3 p-2">
<h1 className="text-2xl">React-hook-formを使用したフォームサンプル集</h1>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<ul className="flex flex-col gap-2">
<li>
<p>フルネーム(入力必須, 20文字以内)</p>
<input
type="text"
{...register("fullName", {
required: "フルネームは必須です。",
maxLength: { value: 20, message: "20文字以内で入力してください。" },
})}
/>
<p className="text-red-500">{errors.fullName?.message}</p>
</li>
<li>
<p>ハンドルネーム(任意入力)</p>
<input type="text" {...register("handleName")} />
</li>
<li>
<p>年齢(18~70歳まで)</p>
<input
type="number"
{...register("age", {
required: "年齢は入力必須です",
valueAsNumber: true,
min: { value: 18, message: "18歳〜70歳までが利用可能です" },
max: { value: 70, message: "18歳〜70歳までが利用可能です" },
})}
/>
<p className="text-red-500">{errors.age?.message}</p>
</li>
<li>
<p>生年月日(YYYY/MM/DD)</p>
<input
type="date"
{...register("birthDate", {
required: "生年月日は必須です",
setValueAs: (value) => dayjs(value).format("YYYY/MM/DD"),
})}
/>
<p className="text-red-500">{errors.birthDate?.message}</p>
</li>
<li>
<p>性別(選択必須)</p>
<label>
<input type="radio" value="0" {...register("gender", { required: "性別は必須です" })} /> 男
</label>
<label>
<input type="radio" value="1" {...register("gender")} /> 女
</label>
<label>
<input type="radio" value="2" {...register("gender")} /> その他
</label>
<p className="text-red-500">{errors.gender?.message}</p>
</li>
<li>
<p>興味のあるジャンル(必須)</p>
<label>
<input type="radio" value="dog" {...register("interestedIn", { required: "必須項目です" })} /> 犬
</label>
<label>
<input type="radio" value="cat" {...register("interestedIn")} /> 猫
</label>
<label>
<input type="radio" value="other" {...register("interestedIn")} /> その他
</label>
<p className="text-red-500">{errors.interestedIn?.message}</p>
{selectedInterestIsOther && (
<>
<input
type="text"
{...register("otherInterest", { required: "その他を選んだ場合は入力してください" })}
/>
<p className="text-red-500">{errors.otherInterest?.message}</p>
</>
)}
</li>
<li>
<p>趣味・特技(任意入力)</p>
<label>
<input type="checkbox" value="sports" {...register("hobby")} />
スポーツ
</label>
<label>
<input type="checkbox" value="study" {...register("hobby")} />
勉強
</label>
<label>
<input type="checkbox" value="reading" {...register("hobby")} />
読書
</label>
</li>
<li>
<p>連絡方法(必須)</p>
<label>
<input
type="checkbox"
value="phone"
{...register("contactMethod", { required: "連絡方法は入力必須です" })}
/>
電話
</label>
<label>
<input
type="checkbox"
value="email"
{...register("contactMethod", { required: "連絡方法は入力必須です" })}
/>
メール
</label>
<label>
<input
type="checkbox"
value="line"
{...register("contactMethod", { required: "連絡方法は入力必須です" })}
/>
LINE
</label>
<p className="text-red-500">{errors.contactMethod?.message}</p>
</li>
<li>
<p>好きな色(2つまで選択可)</p>
<label>
<input
type="checkbox"
value="red"
{...register("likeColor", { validate: (color) => color.length <= 2 || "2個まで選択可能です" })}
/>
赤
</label>
<label>
<input type="checkbox" value="blue" {...register("likeColor")} /> 青
</label>
<label>
<input type="checkbox" value="yellow" {...register("likeColor")} /> 黄色
</label>
<label>
<input type="checkbox" value="green" {...register("likeColor")} /> 緑
</label>
<p className="text-red-500">{errors.likeColor?.message}</p>
</li>
<li>
<p>都道府県(任意)</p>
<select {...register("prefecture")}>
<option value="">-- 都道府県を選択 --</option>
<option value="tokyo">東京</option>
<option value="oosaka">大阪</option>
<option value="fukuoka">福岡</option>
<option value="hokkaido">北海道</option>
</select>
<p className="text-red-500">{errors.prefecture?.message}</p>
</li>
<li>
<p>現在の職業(必須)</p>
<select {...register("works", { required: "現在の職業を選択してください" })}>
<option value="">-- 現在の職業を選択してください--</option>
<option value="0">学生</option>
<option value="1">会社員</option>
<option value="2">公務員</option>
<option value="3">その他</option>
</select>
<p className="text-red-500">{errors.works?.message}</p>
</li>
<li>
<p>プロフィール写真(必須)</p>
<input type="file" {...register("profileImage", { required: "プロフィール写真は必須です" })} />
<p className="text-red-500">{errors.profileImage?.message}</p>
</li>
<li>
<p>カバー写真(任意)</p>
<input type="file" {...register("coverImage")} />
</li>
<li>
<p>その他の写真(3枚まで)</p>
<input
type="file"
multiple
{...register("otherImage", {
validate: (files) => {
if (!files || files.length === 0) return true; // 未選択(null or 空配列)の場合はOK
return files.length <= 3 || "任意写真は3枚までです"; // 3枚まで
},
})}
/>
<p className="text-red-500">{errors.otherImage?.message}</p>
</li>
</ul>
<div className="flex gap-4">
<button type="button" onClick={() => reset(defaultValues)} className="bg-white p-2.5 rounded-xl">
入力内容をリセット
</button>
<button type="submit" className="bg-blue-400 p-2.5 rounded-xl">
入力内容を送信
</button>
</div>
</form>
</div>
</main>
);
}
export default App;
行いたいこと
- 繰り返し表示の箇所はmap化する。
- classNameが重複しすぎるものは別途コンポーネント化する
- 内部ロジックは前回と変わらないものを作る
- 型 -> UI定義配置とバリデーション内容 -> submitEventとしてなるべく処理の同順をわかりやすくすること
実際にやってみた
まずは別途型定義を作成
// レイアウト整備をするためのコンポーネントが受け取る型
export type FormFieldItem = {
formName: string;
formItem: ReactNode;
validationMessage?: string;
};
// ラジオ、チェックボックス、セレクトボックスなどのvalue値管理の型
export type FormOption = {
text: string;
value: string;
};
上記の型を使用した定数での選択肢一覧
import { FormOption } from "../types/formItem";
/**性別選択肢 */
export const GENDER_OPTIONS: FormOption[] = [
{ value: "0", text: "男" },
{ value: "1", text: "女" },
{ value: "2", text: "その他" },
];
/**興味のあるジャンル選択肢 */
export const INTEREST_OPTIONS: FormOption[] = [
{ value: "dog", text: "犬" },
{ value: "cat", text: "猫" },
{ value: "other", text: "その他" },
];
/**趣味・特技選択肢 */
export const HOBBY_OPTIONS: FormOption[] = [
{ value: "sports", text: "スポーツ" },
{ value: "study", text: "勉強" },
{ value: "reading", text: "読書" },
]
/**連絡方法選択肢 */
export const CONTACT_METHOD_OPTIONS: FormOption[] = [
{ value: "phone", text: "電話" },
{ value: "email", text: "メール" },
{ value: "line", text: "LINE" },
]
/**好きな色選択肢 */
export const LIKE_COLOR_OPTIONS: FormOption[] = [
{ value: "red", text: "赤" },
{ value: "blue", text: "青" },
{ value: "yellow", text: "黄" },
{ value: "green", text: "緑" },
];
/**都道府県選択肢 */
export const PREFECTURE_OPTIONS: FormOption[] = [
{ value: "1", text: "東京" },
{ value: "2", text: "大阪" },
{ value: "3", text: "福岡" },
{ value: "4", text: "北海道" },
];
/** 現在のお仕事選択肢*/
export const WORKS_OPTIONS: FormOption[] = [
{ value: "1", text: "学生" },
{ value: "2", text: "ITエンジニア" },
{ value: "3", text: "運転手" },
{ value: "4", text: "飲食店" },
];
フォームフィールドとして一覧のレイアウトを縦並びに行間調整するコンポーネント
import React, { FC, ReactNode } from "react";
import { FormFieldItem } from "../types/formItem";
import FormFieldChild from "./FormFieldChild";
type Props = {
formFieldItems: FormFieldItem[];
};
const FormField: FC<Props> = ({ formFieldItems }) => {
return (
<div>
<ul className="flex flex-col gap-2">
{formFieldItems.map((formFieldItem) => (
<FormFieldChild key={formFieldItem.formName} formFieldItem={formFieldItem} />
))}
</ul>
</div>
);
};
export default FormField;
フォームフィールドで表示される一覧の子要素レイアウト整理のコンポーネント
import React, { FC } from "react";
import { FormFieldItem } from "../types/formItem";
type Props = {
formFieldItem: FormFieldItem;
};
const FormFieldChild: FC<Props> = ({ formFieldItem }) => {
const { formName, formItem, validationMessage } = formFieldItem;
return (
<li className="flex flex-col gap-1">
<p className="font-bold">{formName}</p>
<div>{formItem}</div>
{validationMessage && <p className="text-red-500">{validationMessage}</p>}
</li>
);
};
export default FormFieldChild;
ラジオボタンやセレクトボックスの横並び行間を調整するコンポーネント
import React, { FC, ReactNode } from "react";
type Props = {
children: ReactNode;
};
const FormStack: FC<Props> = ({ children }) => {
return <div className="flex items-center gap-1.5">{children}</div>;
};
export default FormStack;
完成したApp.tsx
import dayjs from "dayjs";
import { SubmitHandler, useForm } from "react-hook-form";
import { FormFieldItem } from "./types/formItem";
import FormField from "./components/FormField";
import FormStack from "./components/FormStack";
import {
CONTACT_METHOD_OPTIONS,
GENDER_OPTIONS,
HOBBY_OPTIONS,
INTEREST_OPTIONS,
LIKE_COLOR_OPTIONS,
PREFECTURE_OPTIONS,
WORKS_OPTIONS,
} from "./constants/formOption";
type FormValues = {
fullName: string; // フルネーム(入力必須)
handleName: string; // ハンドルネーム(任意入力)
age: number; // 年齢(数値)
birthDate: string; // 生年月日(YYYY/MM/DD)
gender: string; // 性別(ラジオボタン、選択必須)
interestedIn: string; // 興味のあるジャンル(ラジオボタン)
otherInterest: string; // 興味のあるジャンル("その他"を選択した場合)
hobby: string[]; // 趣k味・特技(チェックボックス、任意)
contactMethod: string[]; // 連絡方法(チェックボックス、選択必須)
likeColor: string[]; // 好きな色(チェックボックス、2個まで選択可能)
prefecture: string; // 都道府県(セレクトボックス、任意)
works: string; // 職業(セレクトボックス、選択必須)
profileImage: FileList | null; // プロフィール写真(1ファイル必須)
coverImage: FileList | null; // カバー写真(任意)
otherImage: FileList | null; // その他の写真(最大3ファイル、任意)
};
function App() {
const defaultValues: FormValues = {
fullName: "",
handleName: "",
age: 20,
birthDate: "",
gender: "0",
interestedIn: "",
otherInterest: "",
hobby: [],
contactMethod: [],
likeColor: [],
prefecture: "",
works: "",
profileImage: null,
coverImage: null,
otherImage: null,
};
const {
register,
handleSubmit,
formState: { errors },
watch,
reset,
} = useForm<FormValues>({ defaultValues });
const selectedInterest = watch("interestedIn"); //「興味のあるジャンル」で
const selectedInterestIsOther = selectedInterest === "other"; //「その他」を選んでいることを監視する
console.log(errors);
const formFieldItems: FormFieldItem[] = [
{
formName: "フルネーム(入力必須, 20文字以内)",
formItem: (
<input
type="text"
{...register("fullName", {
required: "フルネームは必須です。",
maxLength: { value: 20, message: "20文字以内で入力してください。" },
})}
/>
),
validationMessage: errors.fullName?.message,
},
{
formName: "ハンドルネーム(任意入力)",
formItem: <input type="text" {...register("handleName")} />,
validationMessage: errors.handleName?.message,
},
{
formName: "年齢(18~70歳まで)",
formItem: (
<input
type="number"
{...register("age", {
required: "年齢は入力必須です",
valueAsNumber: true,
min: { value: 18, message: "18歳〜70歳までが利用可能です" },
max: { value: 70, message: "18歳〜70歳までが利用可能です" },
})}
/>
),
validationMessage: errors.age?.message,
},
{
formName: "生年月日(YYYY/MM/DD)",
formItem: (
<input
type="date"
{...register("birthDate", {
required: "生年月日は必須です",
setValueAs: (value) => dayjs(value).format("YYYY/MM/DD"),
})}
/>
),
validationMessage: errors.birthDate?.message,
},
{
formName: "性別(選択必須)",
formItem: (
<FormStack>
{GENDER_OPTIONS.map((item) => (
<label key={item.value}>
<input type="radio" value={item.value} {...register("gender", { required: "性別は必須です" })} />
{item.text}
</label>
))}
</FormStack>
),
validationMessage: errors.gender?.message,
},
{
formName: "興味のあるジャンル(必須)",
formItem: (
<>
<FormStack>
{INTEREST_OPTIONS.map((item) => (
<label key={item.value}>
<input type="radio" value={item.value} {...register("interestedIn", { required: "必須項目です" })} />
{item.text}
</label>
))}
</FormStack>
{selectedInterestIsOther && (
<>
<input type="text" {...register("otherInterest", { required: "その他を選んだ場合は入力してください" })} />
<p className="text-red-500">{errors.otherInterest?.message}</p>
</>
)}
</>
),
validationMessage: errors.interestedIn?.message,
},
{
formName: "趣味・特技(任意入力)",
formItem: (
<FormStack>
{HOBBY_OPTIONS.map((item) => (
<label>
<input type="checkbox" value={item.value} />
{item.text}
</label>
))}
</FormStack>
),
validationMessage: errors.hobby?.message,
},
{
formName: "連絡方法(必須)",
formItem: (
<FormStack>
{CONTACT_METHOD_OPTIONS.map((item) => (
<label>
<input
type="checkbox"
value={item.value}
{...register("contactMethod", { required: "連絡方法は入力必須です" })}
/>
{item.text}
</label>
))}
</FormStack>
),
validationMessage: errors.contactMethod?.message,
},
{
formName: "好きな色(2つまで選択可)",
formItem: (
<FormStack>
{LIKE_COLOR_OPTIONS.map((item) => (
<label key={item.value}>
<input
type="checkbox"
value={item.value}
{...register("likeColor", {
validate: (color) => color.length <= 2 || "2個まで選択可能です",
})}
/>
{item.text}
</label>
))}
</FormStack>
),
validationMessage: errors.likeColor?.message,
},
{
formName: "都道府県(任意)",
formItem: (
<select {...register("prefecture")}>
<option value="">-- 都道府県を選択 --</option>
{PREFECTURE_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.text}
</option>
))}
</select>
),
validationMessage: errors.prefecture?.message,
},
{
formName: "現在の職業(必須)",
formItem: (
<select {...register("works")}>
<option value="">-- 現在の職業を選択 --</option>
{WORKS_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.text}
</option>
))}
</select>
),
validationMessage: errors.prefecture?.message,
},
{
formName: "プロフィール写真(必須)",
formItem: <input type="file" {...register("profileImage", { required: "プロフィール写真は必須です" })} />,
validationMessage: errors.profileImage?.message,
},
{
formName: "カバー写真(任意)",
formItem: <input type="file" {...register("coverImage")} />,
validationMessage: errors.coverImage?.message,
},
{
formName: "その他の写真(3枚まで)",
formItem: (
<input
type="file"
multiple
{...register("otherImage", {
validate: (files) => {
if (!files || files.length === 0) return true; // 未選択(null or 空配列)の場合はOK
return files.length <= 3 || "任意写真は3枚までです"; // 3枚まで
},
})}
/>
),
validationMessage: errors.otherImage?.message,
},
];
const onSubmit: SubmitHandler<FormValues> = (data) => {
alert("クリックしたよ");
console.log(data);
};
return (
<main className="bg-gray-400 h-screen">
<div className="flex flex-col gap-3 p-2">
<h1 className="text-2xl">React-hook-formを使用したフォームサンプル集</h1>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<FormField formFieldItems={formFieldItems} />
<div className="flex gap-4">
<button type="button" onClick={() => reset(defaultValues)} className="bg-white p-2.5 rounded-xl">
入力内容をリセット
</button>
<button type="submit" className="bg-blue-400 p-2.5 rounded-xl">
入力内容を送信
</button>
</div>
</form>
</div>
</main>
);
}
export default App;
-
コードの短縮化, フォームアイテムの一覧としての見やすさ, 拡張性という点でだいぶコードとしては読みやすくなったのではないかと思います。
-
個人的にフォームを作る際ののコーディング順で、
型 -> hooks -> state -> 関数 -> 展開されるリストの配列定義 -> submitEvent -> returnされるHTML要素としています。(コードでの一覧管と出力画面と同等の並び順になるような意識をしている)
完成した画面としては以下ですが、前回とほぼ同じです。ちょっとレイアウト整ったぐらいとなりました。
残課題の整理
今後の記事更新でやってみようと思うことです。
更新出来次第リンクとして追加していきたいと思っています。
新規登録フォーム想定となったため、このフォームに通信取得する初期値を持たせる
axiosでjsonファイルをfetchして、初期入力値を通信後に適用させる(続編ついかしました)
React-hook-formを使用した選択中のファイルプレビュー表示(画像)
プレビュー表示(画像以外のファイルなど)
最後に
引き続きアウトプット更新していきます。ありがとうございました。