フォームを作ろう
Reactでフォームを手作りします。入力値の管理、バリデーション、エラー表示を自前で実装することで、フォームの本質的な仕組みと、次のチャプターで紹介するライブラリの価値を体感します。
このチャプターで学ぶこと
- 制御コンポーネント(Controlled Component)の概念を理解できる
- 複数のStateを使ってフォームの入力値を管理できる
- 手動バリデーションのロジックを実装できる
- エラーメッセージを適切に表示できる
- フォームの送信処理を実装できる
- 手動実装の課題(ボイラープレートの多さ)を実感できる
フォームを作ろう
さあ、いよいよ実践的なUIの登場です。Webアプリを作るうえで、フォームは避けて通れません。ユーザー登録、ログイン、お問い合わせ……あらゆる場面でフォームが必要です。
このチャプターでは、あえてライブラリを使わずに、Reactのプリミティブな機能だけで登録フォームを作り切ります。「なぜあえて?」と思いますか?次のチャプターで紹介するライブラリの価値を、骨身に染みて理解してもらうためです。
まずは、苦労を味わいましょう。
制御コンポーネント(Controlled Component)
HTMLのフォームをそのままJSXに書いても、入力値はReactのStateとは連携していません。ReactでフォームUIを扱う基本的なパターンが 制御コンポーネント(Controlled Component) です。
考え方はシンプルです:
- 入力値をStateで管理する
<input>のvalueにそのStateを渡すonChangeでStateを更新する
これでReactが入力値を完全にコントロールします。
import { useState } from 'react';
function NameInput() {
// 入力値をStateで管理する
const [name, setName] = useState('');
return (
<div>
<input
type="text"
value={name} // StateをValueに渡す
onChange={(e) => setName(e.target.value)} // 変更があればStateを更新
placeholder="お名前を入力"
/>
<p>入力中: {name}</p>
</div>
);
}
e.target.value は、変更されたinput要素の現在の値です。これをStateにセットすることで、Reactの世界に入力値を取り込めます。
Laravelには old('field_name') というヘルパー関数があります。バリデーションエラーでリダイレクトされた後も、ユーザーが入力した値をフォームに表示し続けるための仕組みです。
<input type="text" name="name" value="{{ old('name') }}">Reactの制御コンポーネントも本質的には同じです。「Stateに保存されている値をフォームに表示する」というサイクルを常に回しています。ただし、Laravelのようにリクエスト→レスポンスのサイクルを経る必要はなく、リアルタイムに同期されます。
なぜ value と onChange をセットで書くのか
value だけを渡して onChange を書かないと、Reactからこんな警告が出ます:
Warning: You provided a
valueprop to a form field without anonChangehandler.
value をStateに固定した時点で、ユーザーがタイプしてもReactは「いや、値はStateが決めている」と判断して画面に反映しません。だからStateを更新する onChange が必ずセットで必要になります。これが「制御(Controlled)」の意味です。
登録フォームを作る
それでは本題に入りましょう。以下の4つのフィールドを持つ登録フォームを手作りします:
- 名前(name)
- メールアドレス(email)
- パスワード(password)
- パスワード確認(confirmPassword)
Step 1: フィールドごとにStateを用意する
まず、4つのフィールドそれぞれのStateを用意します。
import { useState } from 'react';
function RegistrationForm() {
// フィールドごとにStateを宣言する
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
return (
<form>
<div>
<label>名前</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div>
<label>メールアドレス</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label>パスワード</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div>
<label>パスワード(確認)</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
<button type="submit">登録する</button>
</form>
);
}
4つのフィールドで、もう4つの useState、4つの onChange ハンドラーが必要です。フィールドが増えるほど、このリストは長くなります。
Step 2: バリデーションのStateを追加する
次に、エラーメッセージを管理するStateを追加します。各フィールドに対して、エラー文字列を保持するStateが必要です。
function RegistrationForm() {
// 入力値のState(4つ)
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
// エラーメッセージのState(4つ追加)
const [nameError, setNameError] = useState('');
const [emailError, setEmailError] = useState('');
const [passwordError, setPasswordError] = useState('');
const [confirmPasswordError, setConfirmPasswordError] = useState('');
// ...
}
4フィールドの登録フォームを作るだけで、もう 8つの useState が並びました。これは少し多いと感じませんか?まだバリデーションロジックにも送信処理にも入っていません。
Step 3: バリデーションロジックを実装する
バリデーションは手動で書く必要があります。条件ごとにチェックして、エラー文字列をセットします。
function validate() {
// バリデーション結果を追跡するフラグ
let isValid = true;
// 名前のバリデーション
if (!name.trim()) {
setNameError('名前を入力してください');
isValid = false;
} else {
setNameError(''); // エラーがなければクリアする
}
// メールアドレスのバリデーション
if (!email.trim()) {
setEmailError('メールアドレスを入力してください');
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
// 正規表現でメール形式をチェック
setEmailError('有効なメールアドレスを入力してください');
isValid = false;
} else {
setEmailError('');
}
// パスワードのバリデーション
if (!password) {
setPasswordError('パスワードを入力してください');
isValid = false;
} else if (password.length < 8) {
setPasswordError('パスワードは8文字以上にしてください');
isValid = false;
} else {
setPasswordError('');
}
// パスワード確認のバリデーション
if (!confirmPassword) {
setConfirmPasswordError('パスワード(確認)を入力してください');
isValid = false;
} else if (password !== confirmPassword) {
setConfirmPasswordError('パスワードが一致しません');
isValid = false;
} else {
setConfirmPasswordError('');
}
return isValid;
}
各フィールドに対して「空チェック → 形式チェック → エラーをセット or クリア」という同じパターンを繰り返しています。4フィールドだとこの長さですが、10フィールドのフォームではどうなるでしょう…。
Step 4: 送信処理をつなぐ
フォームの onSubmit でバリデーションを実行し、パスすれば送信処理に進みます。
// 送信成功フラグ
const [isSubmitted, setIsSubmitted] = useState(false);
function handleSubmit(e) {
// デフォルトのHTMLフォーム送信(ページリロード)を止める
e.preventDefault();
// バリデーションを実行する
const isValid = validate();
if (isValid) {
// バリデーションが通ったら送信処理
console.log('送信するデータ:', { name, email, password });
setIsSubmitted(true);
}
}
HTMLのフォームは submit イベントが発火するとデフォルトでページをリロードします。SPAのReactではそれは困るので e.preventDefault() でデフォルト動作をキャンセルします。LaravelのAjax送信で event.preventDefault() を呼ぶのと同じです。
Step 5: エラーメッセージを表示する
最後に、JSXにエラーメッセージの表示を追加します。
<div>
<label>名前</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
{/* エラーがあれば表示する */}
{nameError && <p style={{ color: 'red' }}>{nameError}</p>}
</div>
これをすべてのフィールドに追加します。
完成版コード
ここまでの内容を一つにまとめると、こうなります。覚悟して読んでください。
import { useState } from 'react';
function RegistrationForm() {
// =====================
// 入力値のState(4つ)
// =====================
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
// =====================
// エラーのState(4つ)
// =====================
const [nameError, setNameError] = useState('');
const [emailError, setEmailError] = useState('');
const [passwordError, setPasswordError] = useState('');
const [confirmPasswordError, setConfirmPasswordError] = useState('');
// 送信成功フラグ
const [isSubmitted, setIsSubmitted] = useState(false);
// =====================
// バリデーション関数
// =====================
function validate() {
let isValid = true;
if (!name.trim()) {
setNameError('名前を入力してください');
isValid = false;
} else {
setNameError('');
}
if (!email.trim()) {
setEmailError('メールアドレスを入力してください');
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setEmailError('有効なメールアドレスを入力してください');
isValid = false;
} else {
setEmailError('');
}
if (!password) {
setPasswordError('パスワードを入力してください');
isValid = false;
} else if (password.length < 8) {
setPasswordError('パスワードは8文字以上にしてください');
isValid = false;
} else {
setPasswordError('');
}
if (!confirmPassword) {
setConfirmPasswordError('パスワード(確認)を入力してください');
isValid = false;
} else if (password !== confirmPassword) {
setConfirmPasswordError('パスワードが一致しません');
isValid = false;
} else {
setConfirmPasswordError('');
}
return isValid;
}
// =====================
// 送信ハンドラー
// =====================
function handleSubmit(e) {
e.preventDefault();
const isValid = validate();
if (isValid) {
console.log('送信データ:', { name, email, password });
setIsSubmitted(true);
}
}
// 送信成功時のメッセージ
if (isSubmitted) {
return <p>登録が完了しました!ようこそ、{name}さん!</p>;
}
// =====================
// JSX
// =====================
return (
<form onSubmit={handleSubmit}>
<div>
<label>名前</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
{nameError && <p style={{ color: 'red' }}>{nameError}</p>}
</div>
<div>
<label>メールアドレス</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{emailError && <p style={{ color: 'red' }}>{emailError}</p>}
</div>
<div>
<label>パスワード</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{passwordError && <p style={{ color: 'red' }}>{passwordError}</p>}
</div>
<div>
<label>パスワード(確認)</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
{confirmPasswordError && (
<p style={{ color: 'red' }}>{confirmPasswordError}</p>
)}
</div>
<button type="submit">登録する</button>
</form>
);
}
export default RegistrationForm;
完成!でも…振り返ってみよう
動くものが作れました。おめでとうございます。でも正直なところを言えば、このコードには問題がたくさんあります。一緒に振り返りましょう。
問題1: useState が多すぎる
4フィールドのフォームで 9つ の useState を書きました:
- 入力値 × 4
- エラーメッセージ × 4
- 送信フラグ × 1
フィールドが10個になったら、useState は20個近くになります。コンポーネントの先頭だけでスクロールが必要になる日も近い。
問題2: onChange ハンドラーが毎回同じパターン
onChange={(e) => setName(e.target.value)}
onChange={(e) => setEmail(e.target.value)}
onChange={(e) => setPassword(e.target.value)}
onChange={(e) => setConfirmPassword(e.target.value)}
これは コピー&ペーストの繰り返し です。フィールドが増えるたびに同じパターンを書き続けることになります。
問題3: バリデーションロジックが長い
4フィールドのバリデーション関数が40行以上になりました。エラーのセットとクリアを両方書かないといけないし、「このフィールドにはどんなルールがあったっけ」をコードから読み解くのも一苦労です。
問題4: 型チェックがない
password.length < 8 のような条件を自分で書いているということは、バリデーションルールの定義とロジックが一体化しています。「8文字以上」というルールを変えたい場合は、バリデーション関数を探してコードを書き換える必要があります。
このチャプターで作ったコードは学習目的で意図的にプリミティブな書き方をしています。実際のプロジェクトでこのアプローチをそのまま使うことは推奨しません。次のチャプターで、この問題を全部まとめて解決する方法を紹介します。
フィールドが10個になったら?
想像してみてください。住所入力フォームを作るとします:
- 名前
- ふりがな
- 電話番号
- 郵便番号
- 都道府県
- 市区町村
- 番地
- 建物名
- メールアドレス
- 確認メール
10フィールドです。useState は最低20個。バリデーション関数は100行超え。onChange は10個のコピー&ペーストライン。
…正直、作りたくないですよね。
この痛みをしっかり覚えておいてください。 次のチャプターで、この問題をエレガントに解決します。
演習: 登録フォームを完成させる
このチャプターで学んだ内容を元に、登録フォームを自分の手で1から実装してみましょう。
要件
フィールド:
- 名前(必須、1文字以上)
- メールアドレス(必須、メール形式)
- パスワード(必須、8文字以上)
- パスワード確認(必須、パスワードと一致すること)
バリデーション:
- 送信ボタンを押したタイミングでバリデーションを実行すること
- エラーがある場合は各フィールドの下にエラーメッセージを表示すること
- すべてのバリデーションが通った場合のみ、
console.logでデータを出力すること
成功時:
- フォームの代わりに「登録が完了しました!」というメッセージを表示すること
ヒント
- まず
useStateを全部宣言してしまう - JSXのフォーム骨格を書く(バリデーションなし)
- バリデーション関数を別で書いてテストする
onSubmitでつなぐ
チェックリスト
- 4つのフィールドが表示されている
- 空のまま送信するとすべてのフィールドにエラーが表示される
- 不正なメール形式でエラーが出る
- 7文字以下のパスワードでエラーが出る
- パスワードが一致しないとエラーが出る
- 正しく入力して送信すると成功メッセージが表示される
完成したら必ずコードの行数を数えてみてください。 次のチャプターで同じフォームを何行で書けるか、楽しみにしていてください。
まとめ
このチャプターでは、Reactのプリミティブな機能だけで登録フォームを作りました。
学んだこと:
- 制御コンポーネント:
value+onChangeでReactがフォームを支配する - 複数のState管理: フィールドとエラーそれぞれに
useStateが必要 - 手動バリデーション: 条件分岐でエラーをセット/クリアするロジック
e.preventDefault(): HTMLのデフォルト送信動作をキャンセルする
そして感じたこと:
useStateの爆発的な増加- コピー&ペーストしたくなる
onChangeハンドラー - 長くなるバリデーション関数
- メンテナンスのしにくさ
この経験があってこそ、次のチャプターの価値がわかります。React のフォームライブラリは、まさにこの苦労を解消するために生まれました。準備はいいですか?