React: フォームと制御コンポーネント

最終更新:2026-10-26

フォームは、Webアプリケーションとユーザーをつなぐ架け橋の役割を果たします。Reactでは、フォームはもはや単に「入力して送信する」だけのものではありません。その代わりに、「Reactはユーザーが入力する文字をすべて把握しています」。このパターンは制御コンポーネントと呼ばれます。


1. 学習内容



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>

質問:

(2) Reactの制御コンポーネント向けのソリューション

▶ 試してみよう

メリット:Reactは各入力フィールドをリアルタイムで追跡し、リアルタイムのバリデーションとリアルタイムのデータ同期を実現するとともに、送信と同時に完全なデータを即座に取得します。



3. 制御対象コンポーネントと非制御対象コンポーネント

100%
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 フックを使用することで、制御コンポーネントの定型コードをカプセル化できます。

📖 まとめ


📝 練習問題

  1. 基本演習(難易度 ⭐):名前、メールアドレス、評価(ドロップダウンオプション:肯定的/中立/否定的)、および内容(テキストエリア)を含む FeedbackForm コンポーネントを作成してください。送信されると、すべてのデータをアラートに表示してください。
  2. 上級問題(難易度 ⭐⭐):パスワードの強度(弱い/中程度/強い)をリアルタイムで表示する PasswordStrengthMeter コンポーネントを作成してください。ルール:8文字以上+大文字・小文字+数字=「強い」;6文字以上=「中程度」;それ以外=「弱い」。
  3. 課題(難易度:⭐⭐⭐):ユーザーがフォーム項目を動的に追加・削除できる DynamicForm コンポーネントを作成してください(例:「家族を複数人追加する」)。各フォーム項目には、名前、年齢、関係性の3つのフィールドを含める必要があります。送信時には、コンポーネントが配列を出力するようにしてください。
Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%