デザインについての学習メモブログ

React入門 #10 – フォーム入力を扱う方法

記事内に広告が含まれています。

React入門 #10 – フォーム入力を扱う方法

Webアプリケーションではユーザーからの入力を扱うことが必須です。

テキスト入力、チェックボックス、セレクトボックス、ラジオボタンなど、様々なフォーム要素があります。

この記事では、Reactでフォーム入力を効果的に扱う方法を詳しく学んでいきます。

制御されたコンポーネント(Controlled Component)

Reactのフォームの核心となる「制御されたコンポーネント」の概念を学びます。

なぜstateで入力値を管理するのか、非制御コンポーネントと何が違うのかを理解することで、以降のすべてのフォーム実装の土台が身につきます。

基本的なテキスト入力

まず、value={value}onChange={(e) => setValue(e.target.value)}の2つに注目してください。

この2点セットが制御されたコンポーネントの最小構成です。

inputに値を「渡す」のと「受け取る」のを、Reactが一手に管理していることを確認しましょう。

入力で変更されるとsetValue()が実行されます。

変更により再レンダリングされ、新しいvalueの値が反映され、画面に表示されます。

JavaScript版:

JSX
import { useState } from 'react'

function TextInput() {
  const [value, setValue] = useState('')
  
  return (
    <div>
      <input
        type="text"
        value={value}
        onChange={(e) => setValue(e.target.value)}
        placeholder="入力してください"
      />
      <p>入力内容: {value}</p>
    </div>
  )
}

export default TextInput

TypeScript版:

TSX
import { type JSX, useState } from 'react'

function TextInput(): JSX.Element {
  const [value, setValue] = useState<string>('')
  
  return (
    <div>
      <input
        type="text"
        value={value}
        onChange={(e) => setValue(e.target.value)}
        placeholder="入力してください"
      />
      <p>入力内容: {value}</p>
    </div>
  )
}

export default TextInput

制御されたコンポーネントとは?

Reactのstateで入力値を管理するコンポーネントを「制御されたコンポーネント」と呼びます。

メリット:

  • 入力値を常にstateで管理できる
  • バリデーションをリアルタイムで行える
  • 入力値をプログラムから変更できる

対比:非制御コンポーネント

非制御コンポーネントのコード例にあるrefの使い方(3,6,11行目)と見比べてください。

useRefについては、次のセクションで解説しますが、DOMへ直接アクセスできるフックの一種と考えてください。

refではボタンを押した瞬間にしか値を取れませんが、制御されたコンポーネントではstateを通じて常に最新の入力値を把握できます。

この違いがReactのフォーム設計の根幹です。

JSX
// ❌ 非制御コンポーネント(非推奨)
import { useRef } from 'react'

function UncontrolledInput() {
  const inputRef = useRef()
  
  const handleSubmit = () => {
    console.log(inputRef.current.value)  // DOMから直接値を取得
  }
  
  return (
    <div>
      <input type="text" ref={inputRef} />
      <button onClick={handleSubmit}>送信</button>
    </div>
  )
}
export default UncontrolledInput

useRefとDOMへの直接アクセス

useRefDOM要素への参照を保持するフックです。

フォーム入力との文脈では、セクション1で触れた「非制御コンポーネント」の実装手段として登場しますが、通常のフォームでは使うべきではありません。

ただし、

stateでは実現しづらい操作(たとえばフォーム送信後に特定のフィールドへフォーカスを戻す)ではuseRefが適切な選択肢になります。

useRefが適切な場面

useRefの用途はDOMへの直接操作に限るのが原則です。

フォームに関わる典型的なユースケースを押さえましょう。

JavaScript版:

ref={inputRef}でDOM要素を参照に紐付け、inputRef.current経由で直接操作していることに注目してください。

JSX
import { useRef } from 'react'

function FocusExample() {
  const inputRef = useRef(null)

  const handleSubmit = (e) => {
    e.preventDefault()
    // 送信後に入力欄へフォーカスを戻す
    inputRef.current.focus()
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        ref={inputRef}
        type="text"
        placeholder="入力してください"
      />
      <button type="submit">送信</button>
    </form>
  )
}

export default FocusExample

TypeScript版:

TypeScript版ではuseRef<HTMLInputElement>(null)と型を指定し、inputRef.current?.focus()のオプショナルチェーンでnullの可能性を安全に処理しています。

TSX
import { type JSX, useRef } from 'react'

function FocusExample(): JSX.Element {
  const inputRef = useRef<HTMLInputElement>(null)

  const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>): void => {
    e.preventDefault()
    // 送信後に入力欄へフォーカスを戻す
    inputRef.current?.focus()
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        ref={inputRef}
        type="text"
        placeholder="入力してください"
      />
      <button type="submit">送信</button>
    </form>
  )
}

export default FocusExample 

useRefを使うべき場面・避けるべき場面

2つのアプローチにはそれぞれ得意な場面があります。

場面制御コンポーネント非制御コンポーネント
リアルタイムバリデーション
入力に連動したUI変化
送信時のみ値を取得したい
シンプルな単発フォーム
フォーカス・スクロールの操作

入力値をリアルタイムで追う必要がある場合は制御コンポーネント、送信時に値が取れればよい軽量なフォームには非制御コンポーネントが向いています。

テキスト関連の入力要素

テキスト・メール・パスワード・テキストエリアなど、よく使うテキスト系フォーム要素を一括管理する方法を学びます。

複数フィールドを持つフォームでも、1つのhandleChangeでスッキリ書けるパターンが習得できます。

テキスト入力フィールド

handleChange関数のconst { name, value } = e.targetと、[name]: valueというブラケット記法に着目してください。

フィールドが何個に増えても、この1つの関数だけで全入力を受け取れる汎用パターンです。

JavaScript版:

JSX
import { useState } from 'react'

function TextInputExample() {
  const [formData, setFormData] = useState({
    text: '',
    email: '',
    password: '',
    textarea: ''
  })
  
  const handleChange = (e) => {
    const { name, value } = e.target
    setFormData({
      ...formData,
      [name]: value
    })
  }
  
  return (
    <div>
      <div>
        <label htmlFor="text">テキスト:</label>
        <input
          id="text"
          type="text"
          name="text"
          value={formData.text}
          onChange={handleChange}
          placeholder="テキストを入力"
        />
      </div>
      
      <div>
        <label htmlFor="email">メール:</label>
        <input
          id="email"
          type="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
          placeholder="メールアドレスを入力"
        />
      </div>
      
      <div>
        <label htmlFor="password">パスワード:</label>
        <input
          id="password"
          type="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
          placeholder="パスワードを入力"
        />
      </div>
      
      <div>
        <label htmlFor="textarea">説明:</label>
        <textarea
          id="textarea"
          name="textarea"
          value={formData.textarea}
          onChange={handleChange}
          placeholder="説明を入力"
          rows={4}
        />
      </div>
    </div>
  )
}

export default TextInputExample

TypeScript版:

TSX
import { type JSX, useState } from 'react'

interface FormData {
  text: string
  email: string
  password: string
  textarea: string
}

function TextInputExample(): JSX.Element {
  const [formData, setFormData] = useState<FormData>({
    text: '',
    email: '',
    password: '',
    textarea: ''
  })
  
  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ): void => {
    const { name, value } = e.target
    setFormData({
      ...formData,
      [name]: value
    })
  }
  
  return (
    <div>
      <div>
        <label htmlFor="text">テキスト:</label>
        <input
          id="text"
          type="text"
          name="text"
          value={formData.text}
          onChange={handleChange}
          placeholder="テキストを入力"
        />
      </div>
      
      <div>
        <label htmlFor="email">メール:</label>
        <input
          id="email"
          type="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
          placeholder="メールアドレスを入力"
        />
      </div>
      
      <div>
        <label htmlFor="password">パスワード:</label>
        <input
          id="password"
          type="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
          placeholder="パスワードを入力"
        />
      </div>
      
      <div>
        <label htmlFor="textarea">説明:</label>
        <textarea
          id="textarea"
          name="textarea"
          value={formData.textarea}
          onChange={handleChange}
          placeholder="説明を入力"
          rows={4}
        />
      </div>
    </div>
  )
}

export default TextInputExample

チェックボックス

checkedプロパティを使った単一・複数チェックボックスの制御方法を学びます。

テキスト入力との違いと、複数の選択状態をオブジェクトで管理するテクニックが身につきます。

単一のチェックボックス

テキスト入力との違いに注目してください。

valueではなくchecked属性を使い、onChangeではe.target.checked(boolean)を取得しています。

チェックボックスはこの2点だけ切り替えれば、ほぼテキスト入力と同じ構造で書けます。

JavaScript版:

JSX
import { useState } from 'react'

function CheckboxExample() {
  const [isChecked, setIsChecked] = useState(false)
  
  return (
    <div>
      <label>
        <input
          type="checkbox"
          checked={isChecked}
          onChange={(e) => setIsChecked(e.target.checked)}
        />
        利用規約に同意します
      </label>
      <p>状態: {isChecked ? '同意' : '未同意'}</p>
    </div>
  )
}

export default CheckboxExample

TypeScript版:

TSX
import { type JSX, useState } from 'react'

function CheckboxExample(): JSX.Element {
  const [isChecked, setIsChecked] = useState<boolean>(false)
  
  return (
    <div>
      <label>
        <input
          type="checkbox"
          checked={isChecked}
          onChange={(e) => setIsChecked(e.target.checked)}
        />
        利用規約に同意します
      </label>
      <p>状態: {isChecked ? '同意' : '未同意'}</p>
    </div>
  )
}

export default CheckboxExample

複数のチェックボックス

stateをオブジェクト{ react: false, javascript: false, ... }で持っている点に着目してください。

handleChange内の[name]: checkedで、チェックしたキーだけを更新する仕組みになっています。

またObject.keys(tags).filter(...)で選択済み項目を取り出す行も合わせて確認しましょう。

JavaScript版:

JSX
import { useState } from 'react'

function MultiCheckbox() {
  const [tags, setTags] = useState({
    react: false,
    javascript: false,
    typescript: false,
    nodejs: false
  })
  
  const handleChange = (e) => {
    const { name, checked } = e.target
    setTags({
      ...tags,
      [name]: checked
    })
  }
  
  const selectedTags = Object.keys(tags).filter(tag => tags[tag])
  
  return (
    <div>
      <div>
        <label>
          <input
            type="checkbox"
            name="react"
            checked={tags.react}
            onChange={handleChange}
          />
          React
        </label>
      </div>
      
      <div>
        <label>
          <input
            type="checkbox"
            name="javascript"
            checked={tags.javascript}
            onChange={handleChange}
          />
          JavaScript
        </label>
      </div>
      
      <div>
        <label>
          <input
            type="checkbox"
            name="typescript"
            checked={tags.typescript}
            onChange={handleChange}
          />
          TypeScript
        </label>
      </div>
      
      <div>
        <label>
          <input
            type="checkbox"
            name="nodejs"
            checked={tags.nodejs}
            onChange={handleChange}
          />
          Node.js
        </label>
      </div>
      
      <p>選択: {selectedTags.join(', ')}</p>
    </div>
  )
}

export default 

TypeScript版:

TSX
import { type JSX, useState } from 'react'

interface Tags {
  react: boolean
  javascript: boolean
  typescript: boolean
  nodejs: boolean
}

function MultiCheckbox(): JSX.Element {
  const [tags, setTags] = useState<Tags>({
    react: false,
    javascript: false,
    typescript: false,
    nodejs: false
  })
  
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
    const { name, checked } = e.target
    setTags({
      ...tags,
      [name]: checked
    })
  }
  
  const selectedTags = Object.keys(tags).filter(tag => tags[tag as keyof Tags])
  
  return (
    <div>
      <div>
        <label>
          <input
            type="checkbox"
            name="react"
            checked={tags.react}
            onChange={handleChange}
          />
          React
        </label>
      </div>
      
      <div>
        <label>
          <input
            type="checkbox"
            name="javascript"
            checked={tags.javascript}
            onChange={handleChange}
          />
          JavaScript
        </label>
      </div>
      
      <div>
        <label>
          <input
            type="checkbox"
            name="typescript"
            checked={tags.typescript}
            onChange={handleChange}
          />
          TypeScript
        </label>
      </div>
      
      <div>
        <label>
          <input
            type="checkbox"
            name="nodejs"
            checked={tags.nodejs}
            onChange={handleChange}
          />
          Node.js
        </label>
      </div>
      
      <p>選択: {selectedTags.join(', ')}</p>
    </div>
  )
}

export default MultiCheckbox

ラジオボタン

複数の選択肢から1つだけ選ぶラジオボタンの実装を学びます。

name属性によるグループ化とvalue比較でcheckedを制御するパターンが理解できます。

JavaScript版:

handleChange内のif (error) { setError('') }に着目してください。

入力のたびにエラーをクリアすることで、ユーザーが「修正できた」と即座にわかる体験を作っています。

バリデーションはsubmit時だけでなく、入力中も反応する設計が重要です。

JSX
import { useState } from 'react'

function RadioButtonExample() {
  const [gender, setGender] = useState('')
  
  return (
    <div>
      <p>性別を選択:</p>
      
      <label>
        <input
          type="radio"
          name="gender"
          value="male"
          checked={gender === 'male'}
          onChange={(e) => setGender(e.target.value)}
        />
        男性
      </label>
      
      <label>
        <input
          type="radio"
          name="gender"
          value="female"
          checked={gender === 'female'}
          onChange={(e) => setGender(e.target.value)}
        />
        女性
      </label>
      
      <label>
        <input
          type="radio"
          name="gender"
          value="other"
          checked={gender === 'other'}
          onChange={(e) => setGender(e.target.value)}
        />
        その他
      </label>
      
      <p>選択: {gender || '未選択'}</p>
    </div>
  )
}

export default RadioButtonExample

TypeScript版:

TSX
import { type JSX, useState } from 'react'

type Gender = 'male' | 'female' | 'other' | ''

function RadioButtonExample(): JSX.Element {
  const [gender, setGender] = useState<Gender>('')
  
  return (
    <div>
      <p>性別を選択:</p>
      
      <label>
        <input
          type="radio"
          name="gender"
          value="male"
          checked={gender === 'male'}
          onChange={(e) => setGender(e.target.value as Gender)}
        />
        男性
      </label>
      
      <label>
        <input
          type="radio"
          name="gender"
          value="female"
          checked={gender === 'female'}
          onChange={(e) => setGender(e.target.value as Gender)}
        />
        女性
      </label>
      
      <label>
        <input
          type="radio"
          name="gender"
          value="other"
          checked={gender === 'other'}
          onChange={(e) => setGender(e.target.value as Gender)}
        />
        その他
      </label>
      
      <p>選択: {gender || '未選択'}</p>
    </div>
  )
}

export default RadioButtonExample

セレクトボックス(ドロップダウン)

単一選択・複数選択(multiple属性)それぞれのセレクトボックスの実装を学びます。

特に複数選択時にArray.fromで選択値を配列に変換するテクニックが習得できます。

基本的なセレクト

<select>valueonChangeを設定するだけで、テキスト入力とほぼ同じ書き方になることを確認してください。

<option>側には特別な記述が不要な点がポイントで、選択状態の管理はすべてstateが担っています。

JavaScript版:

JSX
import { useState } from 'react'

function SelectExample() {
  const [country, setCountry] = useState('')
  
  return (
    <div>
      <label htmlFor="country">国を選択:</label>
      <select
        id="country"
        value={country}
        onChange={(e) => setCountry(e.target.value)}
      >
        <option value="">-- 選択してください --</option>
        <option value="japan">日本</option>
        <option value="usa">アメリカ</option>
        <option value="uk">イギリス</option>
        <option value="france">フランス</option>
      </select>
      <p>選択: {country || '未選択'}</p>
    </div>
  )
}

export default SelectExample

TypeScript版:

TSX
import { type JSX, useState } from 'react'

function SelectExample(): JSX.Element {
  const [country, setCountry] = useState<string>('')
  
  return (
    <div>
      <label htmlFor="country">国を選択:</label>
      <select
        id="country"
        value={country}
        onChange={(e) => setCountry(e.target.value)}
      >
        <option value="">-- 選択してください --</option>
        <option value="japan">日本</option>
        <option value="usa">アメリカ</option>
        <option value="uk">イギリス</option>
        <option value="france">フランス</option>
      </select>
      <p>選択: {country || '未選択'}</p>
    </div>
  )
}

export default SelectExample

💡 HTMLのselected属性は?

通常のHTMLでは、選択状態を表すために<option selected>のように各option側に属性を付けます。

HTML
<!-- 通常のHTML -->
<select>
  <option value="japan" selected>日本</option>
  <option value="usa">アメリカ</option>
</select>

一方Reactでは、<select>valueプロパティを渡すことで、Reactが内部的に「このvalueと一致するoptionのDOM上のselectedプロパティをtrueにする」という処理を代わりに行います。

JSX
// Reactの制御コンポーネント
<select value={country} onChange={...}>
  <option value="japan">日本</option>  // selected属性なし
  <option value="usa">アメリカ</option>
</select>

つまりcountryのstateが"japan"のとき、Reactは再レンダリングのたびに全optionを走査してvalue="japan"と一致するものを探し、そのDOM要素のselectedを自動でセットします。

開発者が各optionを管理する必要がなく、stateという「唯一の正解」だけを見ていればよい設計になっています。

これは制御コンポーネント全体に共通する思想で、「DOMの状態をReactのstateに一致させる責任をReact自身が持つ」ことの具体例です。

テキスト入力のvalue、チェックボックスのcheckedも同じ原理で動いています。

複数選択セレクト

handleChange内のArray.from(e.target.selectedOptions, option => option.value)に注目してください。

multiple属性を加えると選択値が複数になるため、通常のe.target.valueでは取得できません。

selectedOptionsをループして配列に変換するこの1行が、複数選択の核心です。

⚠️ <select multiple>Ctrl(Macの場合Cmd)を押しながらクリックすることで複数選択できます。

UIとしての使いやすさを優先する場合は、チェックボックスのリストに置き換えることも検討してください。

JavaScript版:

JSX
import { useState } from 'react'

function MultiSelectExample() {
  const [selected, setSelected] = useState([])
  
  const handleChange = (e) => {
    const selectedOptions = Array.from(e.target.selectedOptions, option => option.value)
    setSelected(selectedOptions)
  }
  
  return (
    <div>
      <label htmlFor="skills">スキルを選択:</label>
      <select
        id="skills"
        multiple
        value={selected}
        onChange={handleChange}
      >
        <option value="html">HTML</option>
        <option value="css">CSS</option>
        <option value="javascript">JavaScript</option>
        <option value="react">React</option>
        <option value="nodejs">Node.js</option>
      </select>
      <p>選択: {selected.join(', ')}</p>
    </div>
  )
}

export default MultiSelectExample

💡 HTMLOptionsCollectionはそのままでは配列メソッドが使えない

e.target.selectedOptionsの返す型はHTMLOptionsCollectionです。

これはDOM独自のコレクション型で、見た目は配列に似ていますがArrayではないため、mapfilterなどの配列メソッドが使えません。

Array.fromはArray-likeなオブジェクト(配列に似ているが配列ではないもの)を本物の配列に変換するJavaScriptの組み込みメソッドです。

JavaScript
// HTMLOptionsCollectionはそのままでは配列メソッドが使えない
e.target.selectedOptions.map(...) // ❌ エラー

// Array.fromで本物の配列に変換してから操作する
Array.from(e.target.selectedOptions) // ✅ 本物のArrayになる

今回のコードではさらにArray.fromの第2引数にマッピング関数を渡しています。

JavaScript
Array.from(e.target.selectedOptions, option => option.value)

これは「変換しながら同時にmapする」書き方で、以下と同じ意味です。

JavaScript
Array.from(e.target.selectedOptions).map(option => option.value)

第2引数を使うことで変換とマッピングを1ステップで済ませており、コードが少し簡潔になっています。

TypeScript版:

TSX
import { type JSX, useState } from 'react'

function MultiSelectExample(): JSX.Element {
  const [selected, setSelected] = useState<string[]>([])
  
  const handleChange = (e: React.ChangeEvent<HTMLSelectElement>): void => {
    const selectedOptions = Array.from(
      e.target.selectedOptions,
      option => option.value
    )
    setSelected(selectedOptions)
  }
  
  return (
    <div>
      <label htmlFor="skills">スキルを選択:</label>
      <select
        id="skills"
        multiple
        value={selected}
        onChange={handleChange}
      >
        <option value="html">HTML</option>
        <option value="css">CSS</option>
        <option value="javascript">JavaScript</option>
        <option value="react">React</option>
        <option value="nodejs">Node.js</option>
      </select>
      <p>選択: {selected.join(', ')}</p>
    </div>
  )
}

export default MultiSelectExample

入力値のバリデーション

フォーム送信前のバリデーション実装と、エラーメッセージをリアルタイムで表示・クリアする仕組みを学びます。

正規表現・必須チェック・数値チェックなど、実用的なバリデーションパターンが身につきます。

JavaScript版:

3つのポイントに順番に注目してください。

  • validateForm関数でエラーをオブジェクトnewErrorsに集約していること、
  • handleChange内で入力のたびにerrors[name]をクリアしていること、
  • handleSubmitObject.keys(newErrors).length === 0を条件にして送信の可否を判断していること。

この3段構えの構造が、実務のバリデーション実装の基本形です。

JSX
import { useState } from 'react'

function ValidationForm() {
  const [formData, setFormData] = useState({
    email: '',
    password: '',
    age: ''
  })
  
  const [errors, setErrors] = useState({})
  
  const validateForm = () => {
    const newErrors = {}
    
    // メールバリデーション
    if (!formData.email) {
      newErrors.email = 'メールアドレスは必須です'
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
      newErrors.email = '有効なメールアドレスを入力してください'
    }
    
    // パスワードバリデーション
    if (!formData.password) {
      newErrors.password = 'パスワードは必須です'
    } else if (formData.password.length < 8) {
      newErrors.password = 'パスワードは8文字以上である必要があります'
    }
    
    // 年齢バリデーション
    if (!formData.age) {
      newErrors.age = '年齢は必須です'
    } else if (isNaN(formData.age) || formData.age < 18) {
      newErrors.age = '18歳以上である必要があります'
    }
    
    return newErrors
  }
  
  const handleChange = (e) => {
    const { name, value } = e.target
    setFormData({
      ...formData,
      [name]: value
    })
    
    // 入力時にエラーをクリア
    if (errors[name]) {
      setErrors({
        ...errors,
        [name]: ''
      })
    }
  }
  
  const handleSubmit = (e) => {
    e.preventDefault()
    const newErrors = validateForm()
    
    if (Object.keys(newErrors).length === 0) {
      alert('フォーム送信成功!')
      console.log('送信データ:', formData)
    } else {
      setErrors(newErrors)
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">メールアドレス:</label>
        <input
          id="email"
          type="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
          placeholder="メールアドレスを入力"
        />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>
      
      <div>
        <label htmlFor="password">パスワード:</label>
        <input
          id="password"
          type="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
          placeholder="パスワードを入力"
        />
        {errors.password && <span className="error">{errors.password}</span>}
      </div>
      
      <div>
        <label htmlFor="age">年齢:</label>
        <input
          id="age"
          type="number"
          name="age"
          value={formData.age}
          onChange={handleChange}
          placeholder="年齢を入力"
        />
        {errors.age && <span className="error">{errors.age}</span>}
      </div>
      
      <button type="submit">送信</button>
    </form>
  )
}

export default ValidationForm

TypeScript版:

FormErrorsインターフェースの各プロパティに?が付いていることに注目してください。

これはプロパティが「あってもなくてもよい」ことを意味するオプショナル宣言です。

エラーがない項目はオブジェクトのキー自体が存在しない状態になるため、{}(空オブジェクト)が「エラーなし」を表せます。

TSX
import { type JSX, useState } from 'react'

interface FormData {
  email: string
  password: string
  age: string
}

interface FormErrors {
  email?: string
  password?: string
  age?: string
}

function ValidationForm(): JSX.Element {
  const [formData, setFormData] = useState<FormData>({
    email: '',
    password: '',
    age: ''
  })
  
  const [errors, setErrors] = useState<FormErrors>({})
  
  const validateForm = (): FormErrors => {
    const newErrors: FormErrors = {}
    
    if (!formData.email) {
      newErrors.email = 'メールアドレスは必須です'
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
      newErrors.email = '有効なメールアドレスを入力してください'
    }
    
    if (!formData.password) {
      newErrors.password = 'パスワードは必須です'
    } else if (formData.password.length < 8) {
      newErrors.password = 'パスワードは8文字以上である必要があります'
    }
    
    if (!formData.age) {
      newErrors.age = '年齢は必須です'
    } else if (isNaN(Number(formData.age)) || Number(formData.age) < 18) {
      newErrors.age = '18歳以上である必要があります'
    }
    
    return newErrors
  }
  
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
    const { name, value } = e.target
    setFormData({
      ...formData,
      [name]: value
    })
    
    if (errors[name as keyof FormErrors]) {
      setErrors({
        ...errors,
        [name]: ''
      })
    }
  }
  
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
    e.preventDefault()
    const newErrors = validateForm()
    
    if (Object.keys(newErrors).length === 0) {
      alert('フォーム送信成功!')
      console.log('送信データ:', formData)
    } else {
      setErrors(newErrors)
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">メールアドレス:</label>
        <input
          id="email"
          type="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
          placeholder="メールアドレスを入力"
        />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>
      
      <div>
        <label htmlFor="password">パスワード:</label>
        <input
          id="password"
          type="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
          placeholder="パスワードを入力"
        />
        {errors.password && <span className="error">{errors.password}</span>}
      </div>
      
      <div>
        <label htmlFor="age">年齢:</label>
        <input
          id="age"
          type="number"
          name="age"
          value={formData.age}
          onChange={handleChange}
          placeholder="年齢を入力"
        />
        {errors.age && <span className="error">{errors.age}</span>}
      </div>
      
      <button type="submit">送信</button>
    </form>
  )
}

export default ValidationForm

リアルタイム検索フィルター

入力のたびにリストを絞り込む検索フィルターの実装を学びます。

stateとArray.filterを組み合わせるだけで、ライブラリなしにリアルタイム検索が実現できることが理解できます。

JavaScript版:

filteredUsersの定義部分に着目してください。

フィルタリング結果を保持するためのuseStateや、searchTermの変化を監視するuseEffectを使わず、users.filter(...)を変数として直接書くだけで実現しています。

💡 useEffectとは?
「stateが変わったら〇〇を実行する」という副作用を扱うフックの一つです。

詳しくは次回以降の記事で学びますが、今回のフィルタリング実装では使う必要がありません。

searchTermが変わるたびにコンポーネントが再レンダリングされ、filteredUsersも自動的に再計算される。

このReactの仕組みを活かしたシンプルさがポイントです。

JSX
import { useState } from 'react'

function RealTimeFilter() {
  const [users] = useState([
    { id: 1, name: '山田太郎', email: 'yamada@example.com' },
    { id: 2, name: '佐藤花子', email: 'sato@example.com' },
    { id: 3, name: '山田次郎', email: 'yamada2@example.com' },
    { id: 4, name: '田中三郎', email: 'tanaka@example.com' }
  ])
  
  const [searchTerm, setSearchTerm] = useState('')
  
  const filteredUsers = users.filter(user =>
    user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
    user.email.toLowerCase().includes(searchTerm.toLowerCase())
  )
  
  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="名前またはメールで検索"
      />
      
      <p>検索結果: {filteredUsers.length}</p>
      
      <ul>
        {filteredUsers.map(user => (
          <li key={user.id}>
            <h3>{user.name}</h3>
            <p>{user.email}</p>
          </li>
        ))}
      </ul>
    </div>
  )
}

export default RealTimeFilter

TypeScript版:

TSX
import { type JSX, useState } from 'react'

interface User {
  id: number
  name: string
  email: string
}

function RealTimeFilter(): JSX.Element {
  const [users] = useState<User[]>([
    { id: 1, name: '山田太郎', email: 'yamada@example.com' },
    { id: 2, name: '佐藤花子', email: 'sato@example.com' },
    { id: 3, name: '山田次郎', email: 'yamada2@example.com' },
    { id: 4, name: '田中三郎', email: 'tanaka@example.com' }
  ])
  
  const [searchTerm, setSearchTerm] = useState<string>('')
  
  const filteredUsers = users.filter(user =>
    user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
    user.email.toLowerCase().includes(searchTerm.toLowerCase())
  )
  
  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="名前またはメールで検索"
      />
      
      <p>検索結果: {filteredUsers.length}</p>
      
      <ul>
        {filteredUsers.map(user => (
          <li key={user.id}>
            <h3>{user.name}</h3>
            <p>{user.email}</p>
          </li>
        ))}
      </ul>
    </div>
  )
}

export default RealTimeFilter

ファイル入力(ファイル選択)

type="file"の特殊な扱い方と、選択した画像をプレビュー表示するFileReader APIの使い方を学びます。

ファイルのメタ情報(名前・サイズ)の取得方法も合わせて習得できます。

2つの箇所を確認してください。

  • e.target.files?.[0]でファイルを取得していること(filesは配列なので[0]で先頭を取り出す)
  • FileReaderreadAsDataURLで読み込んだ結果をreader.onloadのコールバック内でsetPreviewに渡していること。

非同期で完了するFileReaderの処理を、コールバックの中でstateに反映する流れを追ってみましょう。

JavaScript版:

JSX
import { useState } from 'react'

function FileInput() {
  const [file, setFile] = useState(null)
  const [preview, setPreview] = useState(null)
  
  const handleFileChange = (e) => {
    const selectedFile = e.target.files[0] // ①
    
    if (selectedFile) {  // ②
      setFile(selectedFile) // ③
      
      // 画像のプレビュー表示
      if (selectedFile.type.startsWith('image/')) { // ④
        const reader = new FileReader() // ⑤
        reader.onload = (event) => { // ⑥
          setPreview(event.target.result) // ⑦
        }
        reader.readAsDataURL(selectedFile) // ⑧
      }
    }
  }
  
  const handleSubmit = (e) => {
    e.preventDefault()
    if (file) {
      console.log('ファイル:', file)
      console.log('ファイル名:', file.name)
      console.log('ファイルサイズ:', file.size)
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="file"
        accept="image/*"
        onChange={handleFileChange}
      />
      
      {preview && (
        <div>
          <h3>プレビュー:</h3>
          <img src={preview} alt="プレビュー" style={{ maxWidth: '300px' }} />
        </div>
      )}
      
      {file && (
        <div>
          <p>ファイル名: {file.name}</p>
          <p>ファイルサイズ: {(file.size / 1024).toFixed(2)} KB</p>
        </div>
      )}
      
      <button type="submit" disabled={!file}>
        アップロード
      </button>
    </form>
  )
}

export default FileInput

💡 コードの解説

handleFileChangeの処理フローを箇条書きで整理しました。

処理の順序(時系列)

  1. ①②③④⑤⑥⑧が順番に実行される(⑦はまだ
  2. ファイルの読み込み完了(少し時間がかかる)
  3. ⑦が実行されて画像がプレビュー表示される

①ファイルを取得

②ファイルが選択されたか確認

③選択したファイルをstateに保存: ファイル情報(名前・サイズなど)を取得できる

④画像ファイルかチェック

"image/png""image/jpeg"といったMIMEタイプから

startsWith('image/')で先頭がimage/なら画像と判定

⑤FileReaderを作成

ブラウザ標準のAPIで、ファイルの中身を読み込むためのオブジェクト

⑥読み込み完了時の処理を登録

onloadはFileReaderがファイルを読み終えたときに実行されるコールバック

まだこの時点では実行されない(⑧の処理が完了したときに呼ばれる)

⑦プレビュー用のデータURLをstateに保存

event.target.resultにはdata:image/png;base64,iVBORw0K...のような文字列が入る

これを<img src={preview}>に渡すことで画像が表示できる

⑧ファイルの読み込みを開始

非同期で読み込みが始まる

読み込みが終わると⑥で登録したonloadが実行される

TypeScript版:

TSX
import { type JSX, useState } from 'react'

function FileInput(): JSX.Element {
  const [file, setFile] = useState<File | null>(null)
  const [preview, setPreview] = useState<string | null>(null)
  
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
    const selectedFile = e.target.files?.[0]
    
    if (selectedFile) {
      setFile(selectedFile)
      
      if (selectedFile.type.startsWith('image/')) {
        const reader = new FileReader()
        reader.onload = (event) => {
          setPreview(event.target?.result as string)
        }
        reader.readAsDataURL(selectedFile)
      }
    }
  }
  
  const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>): void => {
    e.preventDefault()
    if (file) {
      console.log('ファイル:', file)
      console.log('ファイル名:', file.name)
      console.log('ファイルサイズ:', file.size)
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="file"
        accept="image/*"
        onChange={handleFileChange}
      />
      
      {preview && (
        <div>
          <h3>プレビュー:</h3>
          <img src={preview} alt="プレビュー" style={{ maxWidth: '300px' }} />
        </div>
      )}
      
      {file && (
        <div>
          <p>ファイル名: {file.name}</p>
          <p>ファイルサイズ: {(file.size / 1024).toFixed(2)} KB</p>
        </div>
      )}
      
      <button type="submit" disabled={!file}>
        アップロード
      </button>
    </form>
  )
}

export default FileInput

フォームのリセット

フォームの全フィールドを初期値に戻す方法を学びます。

stateを初期値で上書きするシンプルなパターンで、「クリア」ボタンや送信後の自動リセットが実装できるようになります。

JavaScript版:

handleReset関数のシンプルさに注目してください。

stateを初期値と同じオブジェクトで上書きするだけです。

またhandleSubmitの末尾でhandleReset()を呼んでいることで、送信後に自動でフォームがクリアされる仕組みになっています。

「リセット」のために特別なAPIは不要で、stateを書き直すだけで完結することを押さえましょう。

JSX
import { useState } from 'react'

function FormReset() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: ''
  })
  
  const handleChange = (e) => {
    const { name, value } = e.target
    setFormData({
      ...formData,
      [name]: value
    })
  }
  
  const handleReset = () => {
    setFormData({
      name: '',
      email: '',
      message: ''
    })
  }
  
  const handleSubmit = (e) => {
    e.preventDefault()
    console.log('送信:', formData)
    handleReset()
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        name="name"
        value={formData.name}
        onChange={handleChange}
        placeholder="名前"
      />
      
      <input
        type="email"
        name="email"
        value={formData.email}
        onChange={handleChange}
        placeholder="メール"
      />
      
      <textarea
        name="message"
        value={formData.message}
        onChange={handleChange}
        placeholder="メッセージ"
      />
      
      <button type="submit">送信</button>
      <button type="button" onClick={handleReset}>
        クリア
      </button>
    </form>
  )
}

export default FormReset

TypeScript版:

TSX
import { type JSX, useState } from 'react'

interface FormData {
  name: string
  email: string
  message: string
}

function FormReset(): JSX.Element {
  const [formData, setFormData] = useState<FormData>({
    name: '',
    email: '',
    message: ''
  })
  
  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ): void => {
    const { name, value } = e.target
    setFormData({
      ...formData,
      [name]: value
    })
  }
  
  const handleReset = (): void => {
    setFormData({
      name: '',
      email: '',
      message: ''
    })
  }
  
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
    e.preventDefault()
    console.log('送信:', formData)
    handleReset()
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        name="name"
        value={formData.name}
        onChange={handleChange}
        placeholder="名前"
      />
      
      <input
        type="email"
        name="email"
        value={formData.email}
        onChange={handleChange}
        placeholder="メール"
      />
      
      <textarea
        name="message"
        value={formData.message}
        onChange={handleChange}
        placeholder="メッセージ"
      />
      
      <button type="submit">送信</button>
      <button type="button" onClick={handleReset}>
        クリア
      </button>
    </form>
  )
}

export default FormReset

動的なフォーム要素の追加・削除

フィールドをユーザー操作で増減できるフォームの実装を学びます。

配列stateとmapfilter・一意なIDを組み合わせる手法が身につき、アンケートや複数入力フォームに応用できます。

3つの操作関数それぞれに注目してください。

  • 追加(handleAddField)はnextIdで一意なIDを振りながらスプレッド構文で配列に追加
  • 削除(handleRemoveField)はfilterで対象IDを除外
  • 更新(handleChange)はmapで対象IDだけ書き換えています。

配列stateを「immutableに操作する」3パターンが一度に学べる構成です。

またdisabled={fields.length === 1}で最低1フィールドを保証している点も見逃さないでください。

JavaScript版:

JSX
import { useState } from 'react'

function DynamicForm() {
  const [fields, setFields] = useState([{ id: 1, value: '' }])
  const [nextId, setNextId] = useState(2)
  
  const handleAddField = () => {
    setFields([...fields, { id: nextId, value: '' }])
    setNextId(nextId + 1)
  }
  
  const handleRemoveField = (id) => {
    setFields(fields.filter(field => field.id !== id))
  }
  
  const handleChange = (id, newValue) => {
    setFields(fields.map(field =>
      field.id === id ? { ...field, value: newValue } : field
    ))
  }
  
  const handleSubmit = (e) => {
    e.preventDefault()
    console.log('入力値:', fields)
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <h2>動的フォーム</h2>
      
      {fields.map(field => (
        <div key={field.id} style={{ marginBottom: '10px' }}>
          <input
            type="text"
            value={field.value}
            onChange={(e) => handleChange(field.id, e.target.value)}
            placeholder="入力"
          />
          <button
            type="button"
            onClick={() => handleRemoveField(field.id)}
            disabled={fields.length === 1}
          >
            削除
          </button>
        </div>
      ))}
      
      <button type="button" onClick={handleAddField}>
        フィールド追加
      </button>
      
      <button type="submit">送信</button>
    </form>
  )
}

export default DynamicForm

TypeScript版:

TSX
import { type JSX, useState } from 'react'

interface Field {
  id: number
  value: string
}

function DynamicForm(): JSX.Element {
  const [fields, setFields] = useState<Field[]>([{ id: 1, value: '' }])
  const [nextId, setNextId] = useState<number>(2)
  
  const handleAddField = (): void => {
    setFields([...fields, { id: nextId, value: '' }])
    setNextId(nextId + 1)
  }
  
  const handleRemoveField = (id: number): void => {
    setFields(fields.filter(field => field.id !== id))
  }
  
  const handleChange = (id: number, newValue: string): void => {
    setFields(fields.map(field =>
      field.id === id ? { ...field, value: newValue } : field
    ))
  }
  
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
    e.preventDefault()
    console.log('入力値:', fields)
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <h2>動的フォーム</h2>
      
      {fields.map(field => (
        <div key={field.id} style={{ marginBottom: '10px' }}>
          <input
            type="text"
            value={field.value}
            onChange={(e) => handleChange(field.id, e.target.value)}
            placeholder="入力"
          />
          <button
            type="button"
            onClick={() => handleRemoveField(field.id)}
            disabled={fields.length === 1}
          >
            削除
          </button>
        </div>
      ))}
      
      <button type="button" onClick={handleAddField}>
        フィールド追加
      </button>
      
      <button type="submit">送信</button>
    </form>
  )
}

export default DynamicForm

フォームのベストプラクティス

保守性・アクセシビリティを高める3つの実装習慣を学びます。

htmlForとid の紐付け、意味のあるname属性、リアルタイムエラー表示という、プロが行っている書き方が整理できます。

プラクティス1: htmlForとidを使う

<label htmlFor="email"><input id="email">の値(ここではemail)が一致していることに注目してください。

この紐付けにより、ラベルをクリックするだけで対応する入力欄にフォーカスが移ります。

アクセシビリティと操作性の両方に直結する最小コストの改善です。

✅ 良い例:

JSX
<label htmlFor="email">メール:</label>
<input id="email" type="email" name="email" />

プラクティス2: 意味のあるフィールド名を使う

name="x"name="email"の2つを見比べてください。

name属性はhandleChange内のe.target.nameで使われるため、意味のない名前にするとデバッグ時にどのフィールドか判別できなくなります。

stateのキー名と揃えるのがベストです。

❌ 悪い例:

JSX
<input name="x" value={x} />

✅ 良い例:

JSX
<input name="email" value={email} />

プラクティス3: エラーメッセージをリアルタイムで表示

handleChange内のif (error) { setError('') }に着目してください。

入力のたびにエラーをクリアすることで、ユーザーが「修正できた」と即座にわかる体験を作っています。

バリデーションはsubmit時だけでなく、入力中も反応する設計が重要です。

✅ 良い例:

JSX
const handleChange = (e) => {
  setValue(e.target.value)
  // エラーをクリア
  if (error) {
    setError('')
  }
}

実践:複雑なログインフォーム

これまで学んだ要素をすべて組み合わせ、実務レベルのログインフォームを構築します。

バリデーション・ローディング状態・成功画面の切り替えまでを一連の流れで実装する経験が積めます。

handleChangetype === 'checkbox' ? checked : valueという三項演算子に注目してください。

テキスト系とチェックボックスを1つの関数で統一的に扱うための分岐です。

加えてisLoadingフラグで全入力要素にdisabled={isLoading}を当てて操作を封じる点、handleSubmitasync/awaitで書いて送信中の状態を管理している点も、実務でそのまま使えるパターンとして確認しましょう。

JavaScript版:

JSX
import { useState } from 'react'

function LoginForm() {
  const [formData, setFormData] = useState({
    email: '',
    password: '',
    rememberMe: false
  })
  
  const [errors, setErrors] = useState({})
  const [isLoading, setIsLoading] = useState(false)
  const [success, setSuccess] = useState(false)
  
  const validateForm = () => {
    const newErrors = {}
    
    if (!formData.email) {
      newErrors.email = 'メールアドレスは必須です'
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
      newErrors.email = '有効なメールアドレスを入力してください'
    }
    
    if (!formData.password) {
      newErrors.password = 'パスワードは必須です'
    } else if (formData.password.length < 6) {
      newErrors.password = 'パスワードは6文字以上である必要があります'
    }
    
    return newErrors
  }
  
  const handleChange = (e) => {
    const { name, value, type, checked } = e.target
    setFormData({
      ...formData,
      [name]: type === 'checkbox' ? checked : value
    })
    
    if (errors[name]) {
      setErrors({
        ...errors,
        [name]: ''
      })
    }
  }
  
  const handleSubmit = async (e) => {
    e.preventDefault()
    
    const newErrors = validateForm()
    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors)
      return
    }
    
    setIsLoading(true)
    
    try {
      // ログイン処理をシミュレート
      await new Promise(resolve => setTimeout(resolve, 1500))
      setSuccess(true)
      console.log('ログイン成功:', formData)
    } catch (error) {
      setErrors({ submit: 'ログインに失敗しました' })
    } finally {
      setIsLoading(false)
    }
  }
  
  if (success) {
    return (
      <div className="success">
        <h2>ログイン成功!</h2>
        <p>メール: {formData.email}</p>
      </div>
    )
  }
  
  return (
    <form onSubmit={handleSubmit} className="login-form">
      <h2>ログイン</h2>
      
      {errors.submit && <div className="error-message">{errors.submit}</div>}
      
      <div className="form-group">
        <label htmlFor="email">メールアドレス</label>
        <input
          id="email"
          type="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
          placeholder="メールアドレスを入力"
          disabled={isLoading}
        />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>
      
      <div className="form-group">
        <label htmlFor="password">パスワード</label>
        <input
          id="password"
          type="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
          placeholder="パスワードを入力"
          disabled={isLoading}
        />
        {errors.password && <span className="error">{errors.password}</span>}
      </div>
      
      <div className="form-group checkbox">
        <label>
          <input
            type="checkbox"
            name="rememberMe"
            checked={formData.rememberMe}
            onChange={handleChange}
            disabled={isLoading}
          />
          ログイン状態を保存する
        </label>
      </div>
      
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'ログイン中...' : 'ログイン'}
      </button>
    </form>
  )
}

export default LoginForm

TypeScript版:

TSX
import { type JSX, useState } from 'react'

interface FormData {
  email: string
  password: string
  rememberMe: boolean
}

interface FormErrors {
  email?: string
  password?: string
  submit?: string
}

function LoginForm(): JSX.Element {
  const [formData, setFormData] = useState<FormData>({
    email: '',
    password: '',
    rememberMe: false,
  })

  const [errors, setErrors] = useState<FormErrors>({})
  const [isLoading, setIsLoading] = useState<boolean>(false)
  const [success, setSuccess] = useState<boolean>(false)

  const validateForm = (): FormErrors => {
    const newErrors: FormErrors = {}

    if (!formData.email) {
      newErrors.email = 'メールアドレスは必須です'
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
      newErrors.email = '有効なメールアドレスを入力してください'
    }

    if (!formData.password) {
      newErrors.password = 'パスワードは必須です'
    } else if (formData.password.length < 6) {
      newErrors.password = 'パスワードは6文字以上である必要があります'
    }

    return newErrors
  }

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
    const { name, value, type, checked } = e.currentTarget
    setFormData({
      ...formData,
      [name]: type === 'checkbox' ? checked : value,
    })

    if (errors[name as keyof FormErrors]) {
      setErrors({
        ...errors,
        [name]: '',
      })
    }
  }

  const handleSubmit = async (
    e: React.SubmitEvent<HTMLFormElement>
  ): Promise<void> => {
    e.preventDefault()

    const newErrors = validateForm()
    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors)
      return
    }

    setIsLoading(true)

    try {
      await new Promise((resolve) => setTimeout(resolve, 1500))
      setSuccess(true)
      console.log('ログイン成功:', formData)
    } catch (error) {
      const message =
        error instanceof Error ? error.message : 'ログインに失敗しました'
      setErrors({ submit: message })
    } finally {
      setIsLoading(false)
    }
  }

  if (success) {
    return (
      <div className="success">
        <h2>ログイン成功!</h2>
        <p>メール: {formData.email}</p>
      </div>
    )
  }

  return (
    <form onSubmit={handleSubmit} className="login-form">
      <h2>ログイン</h2>

      {errors.submit && <div className="error-message">{errors.submit}</div>}

      <div className="form-group">
        <label htmlFor="email">メールアドレス</label>
        <input
          id="email"
          type="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
          placeholder="メールアドレスを入力"
          disabled={isLoading}
        />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>

      <div className="form-group">
        <label htmlFor="password">パスワード</label>
        <input
          id="password"
          type="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
          placeholder="パスワードを入力"
          disabled={isLoading}
        />
        {errors.password && <span className="error">{errors.password}</span>}
      </div>

      <div className="form-group checkbox">
        <label>
          <input
            type="checkbox"
            name="rememberMe"
            checked={formData.rememberMe}
            onChange={handleChange}
            disabled={isLoading}
          />
          ログイン状態を保存する
        </label>
      </div>

      <button type="submit" disabled={isLoading}>
        {isLoading ? 'ログイン中...' : 'ログイン'}
      </button>
    </form>
  )
}

export default LoginForm

まとめ

この記事では、Reactでフォーム入力を扱う方法を詳しく学びました。

重要なポイント:

  • 制御されたコンポーネントでstateを管理する
  • value属性とonChange属性で入力値を追跡
  • チェックボックスとラジオボタンはchecked属性を使う
  • バリデーションはリアルタイムで行う
  • エラーメッセージは入力時にクリアする
  • TypeScriptで型を明示することでバグを防ぐ

フォーム要素の管理方法:

  • テキスト入力:value + onChange
  • チェックボックス:checked + onChange
  • ラジオボタン:checked + onChange(value比較)
  • セレクト:value + onChange
  • テキストエリア:value + onChange

ベストプラクティス:

  • htmlForとidを対応させる
  • 意味のあるフィールド名を使う
  • フォーム送信前にバリデーションする
  • エラーメッセージは入力フィールドの近くに表示
  • 複雑なフォームは複数のステップに分割

次のステップ:

次回は、useEffectを使ったライフサイクル管理とAPIからのデータ取得について学びます。

フォーム入力とAPIを組み合わせることで、サーバーと連携する実用的なアプリケーションが作れるようになります!

フォーム処理はあらゆるWebアプリケーションの基本です。

様々なフォームパターンを実装して、経験を積んでいってください!