この記事を書いた理由
開発経験からmuiを使用したカスタムフックでのフォーム実装経験を積むことはできたのですが、
Reactの開発現場において、よく使われるReact-hook-formの導入をしたことがないため今回その使い方についての記事を書いてみました。
参考記事としては入力欄を使ったものが多いのですが、ラジオボタンやチェックボックスを使ったりするケースってどうやるんだろう?とちょっと疑問だったので、ここで記事に書いてまとめてみたいと思います。
技術選定
- vite
- React
- TypeScript
- React-hook-form
- dayjs(日付入力value値のフォーマットに使用)
- Tailwind-CSS
React Hook Formとは
React Hook FormとはReact用のフォーム作成ライブラリで、様々な現場で採用されている。
非制御コンポーネントと入力内容がRefで管理されるため、stateの記入などをせずとも記入が可能で、レンダリングなどのコストなどからパフォーマンス面での最適化が簡単になる。
この記事を書いている最中にデメリットに感じる点
画面設計時に意図する仕様を完全に再現する点などではやはり人気のライブラリではあるものの学習コストが高そうだなと感じる。ということが気になっており、「とりあえず採用している現場も多いからまぁやり方ぐらいは覚えてみておこうかな」の認識で学習をスタートした。
環境構築
tailWindCSS, dayjsのインストールなどに関しては今回は省略します。
プロジェクトディレクトリで以下のコマンドを実行してreact-hook-formをインストールしましょう。react-hook-formが使えるようになります。
npm i react-hook-form
この記事のサンプルコーディングの条件
- 内部ロジックをわかりやすくするため、.mapでのオブジェクト配列からの繰り返し表示は行わない。(リファクタリング課題として別記事に掲載する予定)
- フォーム管理の内容とバリデーションロジック, utils関数などは全てApp.tsx 1ファイルに収める
React-hook-formでデフォルトで用意されている主要なバリデーション一覧など
バリデーション周り
- required 必須入力
- min 最小値(数値用)
- max 最大値(数値用)
- minLength 最小文字数
- maxLength 最大文字数
- pattern 正規表現チェック
- validate カスタムバリデーションの作成
value値の加工
- valueAsNumber value値の数値変換(input type number用)
- valueAsDate value値の日付変換
- setValueAs カスタム変換(値を加工する)
その他
- disabled 無効化(バリデーション対象外にする)
作ってみるもの
以下を使用したフォームの作成
- 入力欄(空文字不可)
- 入力欄(空文字可能)
- 入力欄(数字のみ)
- ラジオボタン(選択必須)
- ラジオボタン(未選択可能)
- チェックボックス(未選択可能)
- チェックボックス(未選択不可)
- チェックボックス(2個まで選択可能)
- セレクトボックス(未選択可能)
- セレクトボックス(未選択不可)
- ファイルアップローダー(1ファイルのみ必須)
- ファイルアップローダー(未選択可能)
- ファイルアップローダー(複数ファイル(未選択可能))
- submitButton
- 入力内容のリセットボタン
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ファイル、任意)
};
バリデーションロジックとか色々考える必要があるので.... 網羅的にやってみようと思うとまぁこんなもんかなと。ちょっと多いですが、今後のためにも頑張ってみたいと思います。
実際に書いてみた
設定したバリデーションチェックでエラーメッセージを一通り表示させるとこんな感じ
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;
実際に書いていてつまづいた点と気づき、React-hook-formの仕様について
一度 submitEvent が発火すると、バリデーションチェックは常に動作し、エラー判定が行われる
初回のバリデーションエラーはsubmit時にのみ表示されるが、submit後はリアルタイムでエラー表示が更新される
submitバリデーションで初回チェック以降、入力必須バリデーションエラーが出力されている状態で
文字列が入力された時点で自動的にバリデーションエラーのメッセージが消える
再度文字列を消去するとまたバリデーションエラーメッセージが表示される
submitEvent は、設定されたバリデーションに1つでもエラーがあると発生しない
onSubmit定義されている関数がありましたが、この関数はバリデーションエラーに該当するものが存在した時点で実行されないようです。(その前にバリデーションチェックが働くため)
const onSubmit: SubmitHandler<FormValues> = (data) => {
alert("クリックしたよ");
console.log(data);
};
基本的にどこでエラーが起きているのかはメッセージを出力することが出来るので開発中は特定しやすいのですが、
まれにどこで発生しているエラーなのかわからない、という場面がありました。
その場合は
console.log(errors)
を入力しました。レンダリングごとに出力されてしまいますが以下のようにエラー箇所がログ出力可能なので予期せぬバリデーションチェックなどが起きていないか検証することができます。このエラーケースはフォームを表示した時点で即、submitEventを発火させようとしたケースのものです。
valueAsNumber は input type="number" でのみ有効
表題通りとなるのですが、ラジオボタンで制御するvalue値を、最終的にAPI送信時にnumber型に変換したいケースなど
<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>
このようなケースでは、submitEvent内で直接Number()などを用いて変換する必要があるみたいです。
てっきりsubmitでlog出力しようとした時点で即適用されたオブジェクトが出力されるかと思っていたため、試行錯誤したが出来ない....という結論となってしまいました。
formとして保持する値と、API送信などで別途、似たような型情報を用意しなくてはならないようです。
setValueAs は入力後の値を変換するもので、submitEvent 時に変換されるものではない
表題の通りです。
validate
の戻り値のルールについて
戻り値 | バリデーション結果 |
---|---|
true |
バリデーションOK(エラーなし) |
false |
エラーと判定(デフォルトのエラーメッセージなし) |
string |
エラーと判定(指定したエラーメッセージを表示) |
基本的に扱うのはtrueかstringでのエラーメッセージ入力となりそうですね。
falseを使う機会はあまりないかな.....
残課題となった点
今後の記事更新でやってみようと思うことです。
更新出来次第リンクとして追加していきたいと思っています。
- ↓レイアウトを整えたリファクタリングの実施
-
React-hook-formを使用した選択中のファイルプレビュー表示(画像)
-
プレビュー表示(画像以外のファイルなど)
-
新規登録フォーム想定となったため、このフォームに通信取得する初期値を持たせる
axiosでjsonファイルをfetchして、初期入力値を通信後に適用させる
最後に
実務導入するにあたってはもう少し調べごととして必要だなぁと感じました。
hookで自作していたことを再度、ライブラリを使用して1から作ってみましたが割とキャッチアップ観点では面倒だなぁとも感じたり.....ある程度基礎知識はあらためて要求されるなぁと。
とはいえ人気のライブラリ、ある程度知見として持っていきたいため学習を進めます。多分使いこなせれば好きになれそうです。
ありがとうございました。