React: フォームと制御コンポーネント
最終更新:2026-10-26
フォームは、Webアプリケーションとユーザーをつなぐ架け橋の役割を果たします。Reactでは、フォームはもはや単に「入力して送信する」だけのものではありません。その代わりに、「Reactはユーザーが入力する文字をすべて把握しています」。このパターンは制御コンポーネントと呼ばれます。
1. 学習内容
- 制御対象コンポーネントと非制御対象コンポーネント
- input、textarea、select、checkbox、radio に対する制御的なアプローチ
- フォーム送信の手順
- 基本的なフォームの検証
- カスタムフォームフック
2. 登録フォームの変遷
(1) 課題:管理対象外のフォームからのデータ追跡が困難
アリスは、ネイティブメソッドを使用してデータを取得するユーザー登録フォームを作成しました:
HTML
<!-- HTML Form:Read data all at once upon submission -->
<form id="register-form">
<input name="username" id="username" />
<input type="email" name="email" id="email" />
<input type="password" name="password" id="password" />
<select name="role" id="role">
<option value="developer">Developer</option>
<option value="designer">Designer</option>
</select>
<button type="submit">Register</button>
</form>
<script>
document.getElementById('register-form').addEventListener('submit', function(e) {
e.preventDefault()
// 送信時にフォームの値を読み取る
const username = document.getElementById('username').value
const email = document.getElementById('email').value
const password = document.getElementById('password').value
const role = document.getElementById('role').value
// ... Manually verify each field
})
</script>
質問:
- 透明性の欠如:何が入力されているのか?中央値なのか?Reactにはわからない。
- リアルタイムの検証が難しい:入力イベントを監視し、検証ロジックを手動で実装する必要があります。
- フォームデータのバインディングに関する問題:「国を選択すると市外局番が自動的に入力される」といった機能は、手動でコーディングする必要があります
(2) Reactの制御コンポーネント向けのソリューション
▶ 試してみようメリット:Reactは各入力フィールドをリアルタイムで追跡し、リアルタイムのバリデーションとリアルタイムのデータ同期を実現するとともに、送信と同時に完全なデータを即座に取得します。
3. 制御対象コンポーネントと非制御対象コンポーネント
graph TB
subgraph "Controlled Components(Recommendations)"
A1[User Input] --> B1[onChange Event]
B1 --> C1[Update State]
C1 --> D1[React Render Again]
D1 --> E1[value = State]
end
subgraph "Uncontrolled Components"
A2[User Input] --> B2[Update Directly DOM]
B2 --> C2[Use when submitting ref Reading]
end
style C1 fill:#52c41a,color:#fff
style B2 fill:#faad14,color:#000
| 次元 | 制御対象のコンポーネント | 制御対象外のコンポーネント |
|---|---|---|
| データソース | Reactのステート(唯一の真実の源) | DOM自体 |
| アクセス方法 | ステートから直接読み込む | ref または document.getElementById が必要 |
| リアルタイム検証 | ✅ ネイティブ対応 | ❌ 追加の監視が必要 |
| 即時同期 | ✅ リアルタイムのデータ同期 | ❌ 手動での同期が必要 |
| ユースケース | 一般的なフォーム | ファイルのアップロード、簡単な単発のフォーム |
ルール:90%のケースでは、制御済みコンポーネントを使用すること。ファイル
<input type="file">のみ、制御されていないコンポーネントを使用しなければならない。
4. さまざまなフォーム要素に対する制御構文
| フォーム要素 | 関連するプロパティ | onChange の値 | 例 |
|---|---|---|---|
<input type="text"> |
value |
e.target.value |
<input value={name} onChange={e => setName(e.target.value)} /> |
<input type="number"> |
value |
Number(e.target.value) |
手動で数値に変換する必要があります |
<input type="checkbox"> |
checked |
e.target.checked |
ブール値、値なし |
<input type="radio"> |
checked |
e.target.value |
名前でグループ化されています。「checked」は、それらが選択されているかどうかを制御します |
<textarea> |
value |
e.target.value |
Reactは子要素のテキストではなく値を使用する |
<select> |
value |
e.target.value |
複数選択 multiple + 配列 |
<input type="file"> |
❌ 制御なし | e.target.files[0] |
制御なしでのみ使用可能 |
(1) テキスト入力
▶ 試してみよう注:Reactでは、子要素のテキストコンテンツの代わりに
<textarea>プロパティを使用してください。<input type="number">のe.target.valueの値は文字列ですので、必ず数値に変換してください。
(2) チェックボックス
▶ 試してみよう(3) チェックボックスとラジオボタン
▶ 試してみよう出力:
TEXT
入力フィールドと送信処理を持つフォーム。interface で props を型定義した TypeScript の React コンポーネント
▶ サンプル:フォーム要素タイプのクイックリファレンス表
▶ 試してみよう出力:
TEXT
参考:text(value/onChange)、number(Number() 変換)、email、password、textarea(value)、select(value)、checkbox(checked)、radio(checked)、file(非制御、files[0])
5. フォームの検証
| 検証のタイミング | トリガーとなる事象 | メリット | デメリット | 推奨されるシナリオ |
|---|---|---|---|---|
| 入力中 | onChange | リアルタイムのフィードバック。ユーザーは誤りを即座に修正できる | 頻繁にトリガーされる。ユーザーが入力を完了する前にエラーが通知される | パスワードの強度、ユーザー名の検証 |
| フォーカスが外れたとき | onBlur | 注意散漫を防ぐ;完了後にのみ検証を行う | ユーザーはフィールドからカーソルを外してから初めてエラーを確認する | ほとんどのフォームフィールド |
| 送信時 | onSubmit | 最終確認と統合検証 | ユーザーがフォームに入力した後になって初めてエラーに気づく | 最終的なフォールバック検証 |
(1) リアルタイム検証
JSX
function ValidatedForm() {
const [email, setEmail] = React.useState('')
const [password, setPassword] = React.useState('')
const [errors, setErrors] = React.useState({})
const [touched, setTouched] = React.useState({}) // Record which fields have been accessed
function validate(fieldName, value) {
const newErrors = { ...errors }
if (fieldName === 'email') {
if (!value) newErrors.email = 'The email address cannot be left blank.'
else if (!value.includes('@')) newErrors.email = 'The email address format is incorrect.'
else delete newErrors.email
}
if (fieldName === 'password') {
if (!value) newErrors.password = 'The password cannot be empty.'
else if (value.length < 6) newErrors.password = 'Password must be at least 6 chars'
else if (!/[A-Z]/.test(value)) newErrors.password = 'Passwords must contain uppercase letters'
else delete newErrors.password
}
setErrors(newErrors)
}
function handleBlur(e) {
const { name, value } = e.target
setTouched({ ...touched, [name]: true })
validate(name, value)
}
function handleSubmit(e) {
e.preventDefault()
// Validate all fields before submitting
const allErrors = {}
if (!email || !email.includes('@')) allErrors.email = 'Please enter a valid email address'
if (!password || password.length < 6) allErrors.password = 'Password must be at least 6 chars'
if (Object.keys(allErrors).length > 0) {
setErrors(allErrors)
setTouched({ email: true, password: true })
return
}
alert('Submission Successful!')
}
return (
<form onSubmit={handleSubmit} style={{ maxWidth: '400px' }}>
<div style={{ marginBottom: '16px' }}>
<label>Email:</label>
<input
name="email"
type="email"
value={email}
onChange={e => { setEmail(e.target.value); validate('email', e.target.value) }}
onBlur={handleBlur}
style={inputStyle(touched.email && errors.email)}
/>
{touched.email && errors.email && (
<p style={{ color: '#ff4d4f', fontSize: '12px', margin: '4px 0' }}>{errors.email}</p>
)}
</div>
<div style={{ marginBottom: '16px' }}>
<label>Password:</label>
<input
name="password"
type="password"
value={password}
onChange={e => { setPassword(e.target.value); validate('password', e.target.value) }}
onBlur={handleBlur}
style={inputStyle(touched.password && errors.password)}
/>
{touched.password && errors.password && (
<p style={{ color: '#ff4d4f', fontSize: '12px', margin: '4px 0' }}>{errors.password}</p>
)}
</div>
{/* Password Strength Indicator */}
{password && (
<div style={{ marginBottom: '16px' }}>
<p style={{ fontSize: '12px', color: '#666' }}>
Password Strength:
<span style={{ color: password.length < 6 ? '#ff4d4f' : password.length < 10 ? '#faad14' : '#52c41a' }}>
{password.length < 6 ? 'Weak' : password.length < 10 ? 'Medium' : 'Strong'}
</span>
</p>
<div style={{ height: '4px', backgroundColor: '#f0f0f0', borderRadius: '2px' }}>
<div style={{
height: '100%',
width: `${Math.min(100, (password.length / 12) * 100)}%`,
backgroundColor: password.length < 6 ? '#ff4d4f' : password.length < 10 ? '#faad14' : '#52c41a',
borderRadius: '2px',
transition: 'width 0.3s'
}} />
</div>
</div>
)}
<button type="submit" disabled={Object.keys(errors).length > 0}
style={{
width: '100%', padding: '10px',
backgroundColor: Object.keys(errors).length > 0 ? '#d9d9d9' : '#1890ff',
color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer'
}}>
Register
</button>
</form>
)
}
function inputStyle(hasError) {
return {
width: '100%', padding: '8px',
border: `1px solid ${hasError ? '#ff4d4f' : '#d9d9d9'}`,
borderRadius: '4px',
outline: 'none'
}
}
(2) useForm フックをカスタマイズする
フォームのロジックをカスタムフックに抽出することで、再利用を容易にします:
▶ 実行例を展開(91 行)
6. 完全な例:ユーザー登録フォーム
JSX
// ============================================
// Complete Example:User Registration Form
// Features:Controlled Components + Real-Time Verification + Form Data Synchronization
// ============================================
function RegistrationForm() {
const [form, setForm] = React.useState({
username: '',
email: '',
password: '',
confirmPassword: '',
country: '',
gender: '',
acceptTerms: false
})
const [errors, setErrors] = React.useState({})
function validate() {
const newErrors = {}
if (!form.username || form.username.length < 2)
newErrors.username = 'Username must be at least 2 characters'
if (!form.email || !form.email.includes('@'))
newErrors.email = 'Please enter a valid email address'
if (!form.password || form.password.length < 6)
newErrors.password = 'Password must be at least 6 chars'
if (form.password !== form.confirmPassword)
newErrors.confirmPassword = 'The two passwords do not match'
if (!form.country)
newErrors.country = 'Please select a country'
if (!form.acceptTerms)
newErrors.acceptTerms = 'Please agree to the Terms of Service'
return newErrors
}
function handleChange(e) {
const { name, value, type, checked } = e.target
const newValue = type === 'checkbox' ? checked : value
const newForm = { ...form, [name]: newValue }
setForm(newForm)
setErrors(validate())
// Interconnection:Automatically adjust password rule prompts after selecting a country
}
function handleSubmit(e) {
e.preventDefault()
const finalErrors = validate()
setErrors(finalErrors)
if (Object.keys(finalErrors).length === 0) {
alert(`Registration Successful!Welcome ${form.username}`)
}
}
function inputStyle(fieldName) {
return {
width: '100%', padding: '8px', marginBottom: '4px',
border: `1px solid ${errors[fieldName] ? '#ff4d4f' : '#d9d9d9'}`,
borderRadius: '4px'
}
}
return (
<form onSubmit={handleSubmit} style={{ maxWidth: '500px', margin: '0 auto' }}>
<h2>📝 User Registration</h2>
<input name="username" value={form.username} onChange={handleChange}
placeholder="Username" style={inputStyle('username')} />
{errors.username && <p style={errorStyle}>{errors.username}</p>}
<input name="email" type="email" value={form.email} onChange={handleChange}
placeholder="Email" style={inputStyle('email')} />
{errors.email && <p style={errorStyle}>{errors.email}</p>}
<input name="password" type="password" value={form.password} onChange={handleChange}
placeholder="Password (at least 6 chars)" style={inputStyle('password')} />
{errors.password && <p style={errorStyle}>{errors.password}</p>}
<input name="confirmPassword" type="password" value={form.confirmPassword}
onChange={handleChange} placeholder="Confirm Password" style={inputStyle('confirmPassword')} />
{errors.confirmPassword && <p style={errorStyle}>{errors.confirmPassword}</p>}
<select name="country" value={form.country} onChange={handleChange}
style={inputStyle('country')}>
<option value="">-- Select a Country --</option>
<option value="CN">China</option>
<option value="US">United States</option>
<option value="JP">Japan</option>
<option value="BR">Brazil</option>
<option value="SA">Saudi Arabia</option>
</select>
{errors.country && <p style={errorStyle}>{errors.country}</p>}
<div style={{ margin: '12px 0' }}>
<p>Gender:</p>
{['Male', 'Female', 'Other'].map(g => (
<label key={g} style={{ marginRight: '16px' }}>
<input type="radio" name="gender" value={g}
checked={form.gender === g} onChange={handleChange} />
{g}
</label>
))}
</div>
<label style={{ display: 'block', margin: '12px 0' }}>
<input type="checkbox" name="acceptTerms" checked={form.acceptTerms}
onChange={handleChange} />
I have read and agree to <a href="#">User Agreement</a>
</label>
{errors.acceptTerms && <p style={errorStyle}>{errors.acceptTerms}</p>}
<button type="submit" style={{
width: '100%', padding: '10px',
backgroundColor: Object.keys(errors).length === 0 && form.username ? '#1890ff' : '#d9d9d9',
color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer'
}}>
Register
</button>
</form>
)
}
const errorStyle = {
color: '#ff4d4f', fontSize: '12px',
margin: '0 0 8px 0'
}
出力:
TEXT
表示:"Please select"。入力タイプ:text、email、number。ドロップダウン:Please select、Beijing
▶ サンプル:フォームデータのバインディング—国を選択した際に市外局番を自動的に入力する
▶ 試してみよう出力:
TEXT
国セレクター(US/CN/JP/UK/BR)→ 国際番号が自動入力(US: +1、CN: +86)。電話番号入力フィールド + 読み取り専用の国際番号。表示 "Full: +1 5551234"
出力:
TEXT
表示:"Phone Contact"。状態:form(setter: setForm)。入力:Phone number。ドロップダウン:{c}
▶ サンプル:動的フォーム—チームメンバーの追加・削除
▶ 試してみよう出力:
TEXT
動的なメンバー行(Name + Email + Role ドロップダウン)。"+ Add Member" で 1 行追加、"X" で 1 行削除(最低 1 行は保持)。送信 → console.log("Team:", members)
出力:
TEXT
... ('Team:', members)
▶ サンプル:多段階フォームウィザード
▶ 実行例を展開(77 行)
出力:
TEXT
進捗バーのある 3 ステップウィザード。ステップ 1:Name+Email、ステップ 2:Password(6 文字以上)、ステップ 3:City+Terms。NEXT で検証し、BACK で前のステップに戻れる。送信 → "Done!"
出力:
TEXT
表示 "file upload (uncontrolled)"。状態:preview、filename。ボタン:upload。useRef を使用
▶ サンプル:非管理コンポーネント — ファイルのアップロードと useRef
JSX
function FileUploadForm() {
const fileInputRef = useRef(null)
const [preview, setPreview] = useState(null)
const [fileName, setFileName] = useState('')
function handleSubmit(e) {
e.preventDefault()
const file = fileInputRef.current?.files[0]
if (!file) { alert('Please select a file'); return }
console.log('Uploading:', file.name, file.size, 'bytes')
alert(`File "${file.name}" (${(file.size / 1024).toFixed(1)} KB) selected for upload`)
}
function handleFileChange(e) {
const file = e.target.files[0]
if (!file) { setPreview(null); setFileName(''); return }
setFileName(file.name)
if (file.type.startsWith('image/')) {
const reader = new FileReader()
reader.onload = (e) => setPreview(e.target.result)
reader.readAsDataURL(file)
} else {
setPreview(null)
}
}
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '0 auto' }}>
<h3>File Upload (Uncontrolled)</h3>
<div style={{ marginBottom: 12 }}>
<input type="file" ref={fileInputRef} onChange={handleFileChange}
accept="image/*,.pdf,.doc" style={{ marginBottom: 8 }} />
{fileName && <p style={{ fontSize: 13, color: '#666' }}>Selected: {fileName}</p>}
</div>
{preview && (
<div style={{ marginBottom: 12 }}>
<img src={preview} alt="Preview" style={{ maxWidth: '100%', maxHeight: 200, borderRadius: 4, border: '1px solid #eee' }} />
</div>
)}
<button type="submit" style={{ padding: '8px 24px', cursor: 'pointer' }}>Upload</button>
</form>
)
}
出力:
TEXT
ファイル入力 → 画像のプレビュー、またはファイル名を表示。アップロード時に alert "File "photo.jpg" (245.3 KB) selected for upload"
❓ よくある質問
Q
input[type="checkbox"] については、checked と value のどちらを使用すべきですか?A チェックボックスの場合は、
checked attribute (boolean), not value. To retrieve the new value in the corresponding onChange event, use e.target.checked. Similarly, for radio buttons, use checkedを使用してください。Q フォームの検証はいつ行うべきですか?
A 3つのタイミングオプションはそれぞれ異なる目的を持っています:① ユーザーが入力中に検証(onChange) — リアルタイムのフィードバックを提供しますが、頻繁にトリガーされます; ② フィールドのフォーカスが外れたとき(onBlur)の検証 — ユーザーがフォームへの入力を終えてからエラーを表示することで、作業の妨げを最小限に抑えます; ③ 送信時(onSubmit)の検証 — 最終チェックとして機能します。onBlurとonSubmitを組み合わせて使用し、ユーザー名などの重要なフィールドにはリアルタイムのフィードバックを得るためにonChangeを追加することをお勧めします。
Q フォームに多くのフィールドがある場合、
handleChange を扱う際の一般的なアプローチはどのようなものですか?A
e.target.name を、計算プロパティ名である const { name, value } = e.target; setForm(prev => ({...prev, [name]: value})) と組み合わせて使用します。こうすることで、すべての <input name="xxx"> で同じ handleChange を使用できるようになります。チェックボックスについては特別な処理が必要です:const newValue = type === 'checkbox' ? checked : value。Q 制御対象コンポーネントと非制御対象コンポーネントの区別はどのように決めるのですか?
A 簡単なルールがあります。リアルタイムの検証、書式設定、または条件付き表示が必要な場合 → 制御対象コンポーネントを使用します(例:リアルタイムのパスワード強度表示、金額の自動書式設定)。最終的な値のみが重要である場合や、送信時にのみ値を読み取る場合は → 非制御コンポーネントを使用します(例:ファイルのアップロード、単純な問い合わせフォーム)。
<input type="file"> 非制御コンポーネントはvalueが読み取り専用であるため、これしか使用できません。useForm フックを使用することで、制御コンポーネントの定型コードをカプセル化できます。📖 まとめ
- 制御対象のコンポーネント:フォーム要素の値はReactのステートによって制御され、
onChangeを介して更新されます。 - フォームの90%は管理対象コンポーネントを使用しており、
<input type="file">のみが管理対象外のコンポーネントを使用する必要があります - フォームの検証が行われる3つのタイミング:onChange(リアルタイム)、onBlur(フォーカスが外れたとき)、onSubmit(送信時)
- 汎用プロパティ
handleChangeおよびnameを使用して、フォーム処理ロジックを再利用する - カスタム
useFormフックを使用すると、フォームのロジックをカプセル化して再利用できます
📝 練習問題
- 基本演習(難易度 ⭐):名前、メールアドレス、評価(ドロップダウンオプション:肯定的/中立/否定的)、および内容(テキストエリア)を含む
FeedbackFormコンポーネントを作成してください。送信されると、すべてのデータをアラートに表示してください。 - 上級問題(難易度 ⭐⭐):パスワードの強度(弱い/中程度/強い)をリアルタイムで表示する
PasswordStrengthMeterコンポーネントを作成してください。ルール:8文字以上+大文字・小文字+数字=「強い」;6文字以上=「中程度」;それ以外=「弱い」。 - 課題(難易度:⭐⭐⭐):ユーザーがフォーム項目を動的に追加・削除できる
DynamicFormコンポーネントを作成してください(例:「家族を複数人追加する」)。各フォーム項目には、名前、年齢、関係性の3つのフィールドを含める必要があります。送信時には、コンポーネントが配列を出力するようにしてください。