Reactの基礎 - フォーム(制御コンポーネント)

提供: MochiuWiki : SUSE, EC, PCB

📢 Webサイト閉鎖と移転のお知らせ
このWebサイトは2026年9月に閉鎖いたします。
新しい記事は移転先で追加しております。(旧サイトでは記事を追加しておりません)

概要

Reactにおける制御コンポーネント (Controlled Component) は、フォーム要素の値をReactの状態 (state) により管理するパターンである。
HTMLのネイティブフォーム要素はデフォルトで独自の内部状態を保持するが、制御コンポーネントではその状態をReactの useState フックが一元管理する。

制御コンポーネントの核心は、value プロパティと onChange ハンドラを組み合わせたパターンにある。

フォーム要素の value には状態変数を渡し、ユーザの入力を onChange イベントで検知して状態を更新する。
この仕組みにより、Reactの状態がフォーム値の「信頼できる唯一の情報源 (Single Source of Truth)」となる。

状態管理の流れ
番号 内容
1 ユーザがフォーム要素に入力する。
2 onChange イベントが発生する。
3 イベントハンドラが新しい値を取得して、setState で状態を更新する。
4 Reactが再レンダリングを実行する。
5 新しい状態値が、value プロパティ経由でフォーム要素に反映される。


Single Source of Truthを維持することにより、バリデーション、条件付きのボタン無効化、フォーム値の外部API送信等が直感的に実装できるようになる。
また、React DevToolsを通じたデバッグや状態のトレースも容易になる。

なお、value を渡す場合は、必ず同期的な onChange ハンドラが必要である。

onChange 無しで value を指定すると、フォーム要素が読み取り専用になるため注意が必要である。


制御コンポーネントの基本

value + onChangeパターン

制御コンポーネントの基本構造を以下に示す。
useState で状態変数を定義して、その値を value に渡し、onChange で状態を更新する。

 import { useState } from 'react';
 
 export default function BasicControlledInput() {
    const [name, setName] = useState<string>('');
 
    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
       setName(e.target.value);
    };
 
    return (
       <div>
          <input
             type="text"
             value={name}
             onChange={handleChange}
             placeholder="名前を入力"
          />
          <p>入力値: {name}</p>
       </div>
    );
 }


インラインハンドラを使用するとより簡潔に記述できる。
TypeScriptはコンテキストからイベント型を推論するため、インラインの場合は型注釈を省略できる。

 <input
    value={name}
    onChange={e => setName(e.target.value)}
 />


状態管理の流れ

下図に、制御コンポーネントにおける状態管理のサイクルを示す。


この一方向データフロー (Unidirectional Data Flow) により、アプリケーションの状態変化を追跡しやすくなる。

ただし、非制御コンポーネントではDOMが直接値を保持するため、この追跡が困難になる。


テキスト入力

input要素

input 要素の制御コンポーネント化の基本形を以下に示す。
TypeScriptでは React.ChangeEvent<HTMLInputElement> 型を使用してイベントハンドラを型付けする。

 import { useState } from 'react';
 
 export default function TextInputExample() {
    const [text, setText] = useState<string>('');
    const [email, setEmail] = useState<string>('');
    const [password, setPassword] = useState<string>('');
    const [age, setAge] = useState<number>(0);
 
    return (
       <div>
          <input
             type="text"
             value={text}
             onChange={(e: React.ChangeEvent<HTMLInputElement>) => setText(e.target.value)}
             placeholder="テキスト入力"
          />
          <input
             type="email"
             value={email}
             onChange={e => setEmail(e.target.value)}
             placeholder="メールアドレス"
          />
          <input
             type="password"
             value={password}
             onChange={e => setPassword(e.target.value)}
             placeholder="パスワード"
          />
          <input
             type="number"
             value={age}
             onChange={e => setAge(parseInt(e.target.value) || 0)}
             placeholder="年齢"
          />
       </div>
    );
 }


input type="number" の場合、e.target.value は常に文字列として返される点に注意が必要である。

数値として扱う場合は parseInt() または parseFloat() で明示的に変換する。
ただし、空文字列が入力されると parseInt()NaN になるため、|| 0 のようなデフォルト値の処理が必要である。

textarea要素

Reactの textarea 要素はHTMLとは異なる点がある。
HTMLでは <textarea>初期値</textarea> と子要素で値を指定するが、Reactでは value プロパティで管理する。

 import { useState } from 'react';
 
 export default function TextAreaExample() {
    const [content, setContent] = useState<string>('');
 
    return (
       <textarea
          value={content}
          onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setContent(e.target.value)}
          placeholder="内容を入力してください"
          rows={4}
          cols={40}
       />
    );
 }


下表に、HTMLとReactの textarea の違いを示す。

textareaのHTMLとReactの違い
項目 HTML React
初期値の指定 <textarea>初期値</textarea> defaultValue="初期値" または状態管理
子要素 children として指定可能 children は受け入れない (value プロパティのみ)
値の制御 DOM が直接保持 状態変数経由で管理
イベント型 - React.ChangeEvent<HTMLTextAreaElement>



セレクトボックス

単一選択

select 要素の制御コンポーネント化では、HTMLの <option selected> 属性は使用しない。
代わりに、select 要素の value プロパティで選択状態を管理する。

 import { useState } from 'react';
 
 export default function SingleSelectExample() {
    const [selectedFruit, setSelectedFruit] = useState<string>('orange');
 
    return (
       <div>
          <select
             value={selectedFruit}
             onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setSelectedFruit(e.target.value)}
          >
             <option value="">-- 選択してください --</option>
             <option value="apple">りんご</option>
             <option value="banana">バナナ</option>
             <option value="orange">オレンジ</option>
          </select>
          <p>選択中: {selectedFruit}</p>
       </div>
    );
 }


複数選択

multiple 属性を付与すると複数の選択が可能になる。
選択された値は配列として状態管理して、Array.from(e.target.selectedOptions) で選択項目の配列を取得する。

 import { useState } from 'react';
 
 export default function MultipleSelectExample() {
    const [selectedVegetables, setSelectedVegetables] = useState<string[]>(['corn', 'tomato']);
 
    const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
       const selectedOptions = Array.from(e.target.selectedOptions);
       const values = selectedOptions.map(option => option.value);
       setSelectedVegetables(values);
    };
 
    return (
       <div>
          <select
             multiple={true}
             value={selectedVegetables}
             onChange={handleChange}
          >
             <option value="corn">トウモロコシ</option>
             <option value="tomato">トマト</option>
             <option value="cucumber">キュウリ</option>
             <option value="broccoli">ブロッコリー</option>
          </select>
          <p>選択中: {selectedVegetables.join(', ')}</p>
       </div>
    );
 }


e.target.selectedOptions は、HTMLCollection 型であるため、そのままでは配列メソッドが使用できない。
Array.from() でネイティブ配列に変換してから map() で値を取り出す。


チェックボックス

単一チェックボックス

チェックボックスは value ではなく checked プロパティで管理する。
onChange では e.target.checked (boolean) で値を取得する。

 import { useState } from 'react';
 
 export default function SingleCheckboxExample() {
    const [isAgreed, setIsAgreed] = useState<boolean>(false);
 
    return (
       <div>
          <label>
             <input
                type="checkbox"
                checked={isAgreed}
                onChange={(e: React.ChangeEvent<HTMLInputElement>) => setIsAgreed(e.target.checked)}
             />
             利用規約に同意します
          </label>
          <p>同意状態: {isAgreed ? '同意済み' : '未同意'}</p>
       </div>
    );
 }


複数チェックボックスの管理

複数のチェックボックスを管理する場合は、配列の状態で選択済みの値を保持する。
チェック時は配列に値を追加し、チェック解除時は配列から値を削除するトグルロジックを実装する。

 import { useState } from 'react';
 
 interface CheckboxItem {
    id: string;
    label: string;
    value: string;
 }
 
 export default function MultipleCheckboxExample() {
    const [selectedTags, setSelectedTags] = useState<string[]>([]);
 
    const tags: CheckboxItem[] = [
       { id: 'react', label: 'React', value: 'react' },
       { id: 'typescript', label: 'TypeScript', value: 'typescript' },
       { id: 'nodejs', label: 'Node.js', value: 'nodejs' },
       { id: 'graphql', label: 'GraphQL', value: 'graphql' },
    ];
 
    const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
       const { value, checked } = e.target;
 
       setSelectedTags(prev => {
          if (checked) {
             // チェックされた場合、配列に追加
             return [...prev, value];
          } else {
             // チェック解除された場合、配列から削除
             return prev.filter(tag => tag !== value);
          }
       });
    };
 
    return (
       <fieldset>
          <legend>タグを選択してください</legend>
          {tags.map(tag => (
             <label key={tag.id}>
                <input
                   type="checkbox"
                   value={tag.value}
                   checked={selectedTags.includes(tag.value)}
                   onChange={handleCheckboxChange}
                />
                {tag.label}
             </label>
          ))}
          <p>選択済み: {selectedTags.join(', ') || 'なし'}</p>
       </fieldset>
    );
 }


各チェックボックスに対して個別のトグルハンドラを用意する方法も有効である。

 const handleToggle = (tagValue: string) => {
    setSelectedTags(prev => {
       if (prev.includes(tagValue)) {
          return prev.filter(tag => tag !== tagValue);
       } else {
          return [...prev, tagValue];
       }
    });
 };
 
 // 使用例
 <input
    type="checkbox"
    checked={selectedTags.includes('react')}
    onChange={() => handleToggle('react')}
 />



ラジオボタン

ラジオグループ

ラジオボタンのグループは、同じ name 属性を持つ複数の input type="radio" 要素で構成する。
選択状態は文字列の状態で管理し、checked プロパティで各ラジオボタンの選択状態を反映する。

 import { useState } from 'react';
 
 interface RadioOption {
    value: string;
    label: string;
 }
 
 export default function RadioGroupExample() {
    const [selectedRole, setSelectedRole] = useState<string>('user');
 
    const roles: RadioOption[] = [
       { value: 'user', label: 'ユーザ' },
       { value: 'admin', label: '管理者' },
       { value: 'guest', label: 'ゲスト' },
    ];
 
    return (
       <fieldset>
          <legend>ロールを選択してください</legend>
          {roles.map(role => (
             <label key={role.value}>
                <input
                   type="radio"
                   name="role"
                   value={role.value}
                   checked={selectedRole === role.value}
                   onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSelectedRole(e.target.value)}
                />
                {role.label}
             </label>
          ))}
          <p>選択中のロール: {selectedRole}</p>
       </fieldset>
    );
 }


同じ name 属性を持つラジオボタンは自動的に1つのグループを形成する。
ユーザが1つを選択すると、同じ name を持つ他のラジオボタンは自動的に非選択になる。

TypeScriptでリテラル型を使用する場合は以下に示すように型を定義する。

 type Role = 'user' | 'admin' | 'guest';
 
 const [selectedRole, setSelectedRole] = useState<Role>('user');
 
 // onChangeでasアサーションを使用
 onChange={(e) => setSelectedRole(e.target.value as Role)}



複数フィールドの一括管理

オブジェクトstateによる管理

フォームに複数のフィールドが存在する場合、フィールドごとに個別の状態変数を用意するよりも、オブジェクトの状態として一括管理する方が効率的である。
interface でフォームデータの型を定義し、useState<FormData> で初期値を設定する。

 import { useState } from 'react';
 
 interface FormData {
    username: string;
    email: string;
    age: number;
    country: string;
    newsletter: boolean;
 }
 
 export default function MultiFieldFormExample() {
    const [formData, setFormData] = useState<FormData>({
       username: '',
       email: '',
       age: 0,
       country: '',
       newsletter: false,
    });
 
    return (
       <form>
          <input
             type="text"
             name="username"
             value={formData.username}
             onChange={/* 次のセクションで定義する汎用ハンドラを使用 */}
             placeholder="ユーザ名"
          />
          {/* 他のフィールドも同様 */}
       </form>
    );
 }


汎用onChangeハンドラ

name 属性を活用した汎用ハンドラを定義することで、各フィールドに個別のハンドラを用意する必要がなくなる。
e.target.name でフィールド名を取得し、[name]: value の動的プロパティアクセスで該当フィールドのみを更新する。

 import { useState } from 'react';
 
 interface FormData {
    username: string;
    email: string;
    age: number;
    country: string;
    newsletter: boolean;
 }
 
 export default function MultiFieldFormExample() {
    const [formData, setFormData] = useState<FormData>({
       username: '',
       email: '',
       age: 0,
       country: '',
       newsletter: false,
    });
 
    const handleChange = (
       e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
    ) => {
       const { name, type, value } = e.target;
 
       if (type === 'checkbox') {
          // チェックボックスは、e.target.checkedを使用
          const checked = (e.target as HTMLInputElement).checked;
          setFormData(prev => ({
             ...prev,
             [name]: checked,
          }));
       }
       else if (type === 'number') {
          // number型は数値に変換する
          setFormData(prev => ({
             ...prev,
             [name]: parseInt(value) || 0,
          }));
       }
       else {
          // text, email, select等は文字列のまま
          setFormData(prev => ({
             ...prev,
             [name]: value,
          }));
       }
    };
 
    const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
       e.preventDefault();
       console.log('送信データ:', formData);
    };
 
    return (
       <form onSubmit={handleSubmit}>
          <input
             type="text"
             name="username"
             value={formData.username}
             onChange={handleChange}
             placeholder="ユーザ名"
          />
          <input
             type="email"
             name="email"
             value={formData.email}
             onChange={handleChange}
             placeholder="メールアドレス"
          />
          <input
             type="number"
             name="age"
             value={formData.age}
             onChange={handleChange}
             placeholder="年齢"
          />
          <select
             name="country"
             value={formData.country}
             onChange={handleChange}
          >
             <option value="">-- 選択 --</option>
             <option value="japan">日本</option>
             <option value="usa">米国</option>
             <option value="uk">イギリス</option>
          </select>
          <label>
             <input
                type="checkbox"
                name="newsletter"
                checked={formData.newsletter}
                onChange={handleChange}
             />
             ニュースレター購読
          </label>
          <button type="submit">送信</button>
       </form>
    );
 }


下表に、スプレッド構文 {...prev, [name]: value} による状態更新のパターンをまとめる。

スプレッド構文による状態更新パターン
パターン 用途 サンプルコード
単一フィールド更新 1つのフィールドのみ更新 setForm(prev => ({...prev, [name]: value}))
複数フィールド同時更新 複数フィールドを一度に更新 setForm(prev => ({...prev, field1: val1, field2: val2}))
ネストオブジェクト更新 ネストした構造の一部を更新 setForm(prev => ({...prev, address: {...prev.address, city: newCity}}))



フォームのバリデーション

リアルタイムバリデーション

onChange が発生するたびにバリデーションを実行し、エラーメッセージをリアルタイムに表示するパターンを示す。
バリデーション関数を独立して定義することで、テストや再利用が容易になる。

 import { useState } from 'react';
 
 interface FormData {
    username: string;
    email: string;
    password: string;
 }
 
 interface FormErrors {
    [key: string]: string;
 }
 
 const validateForm = (data: FormData): FormErrors => {
    const errors: FormErrors = {};
 
    if (data.username.length < 3) {
       errors.username = 'ユーザ名は3文字以上である必要があります';
    }
 
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(data.email)) {
       errors.email = '有効なメールアドレスを入力してください';
    }
 
    if (data.password.length < 8) {
       errors.password = 'パスワードは8文字以上である必要があります';
    }
 
    return errors;
 };
 
 export default function RealtimeValidationForm() {
    const [formData, setFormData] = useState<FormData>({
       username: '',
       email: '',
       password: '',
    });
 
    const [errors, setErrors] = useState<FormErrors>({});
 
    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
       const { name, value } = e.target;
 
       setFormData(prev => {
          const updated = { ...prev, [name]: value };
          // 状態更新と同時にバリデーションを実行
          const newErrors = validateForm(updated);
          setErrors(newErrors);
          return updated;
       });
    };
 
    return (
       <form>
          <div>
             <input
                type="text"
                name="username"
                value={formData.username}
                onChange={handleChange}
                placeholder="ユーザ名"
             />
             {errors.username && (
                <p style={{ color: 'red' }}>{errors.username}</p>
             )}
          </div>
          <div>
             <input
                type="email"
                name="email"
                value={formData.email}
                onChange={handleChange}
                placeholder="メール"
             />
             {errors.email && <p style={{ color: 'red' }}>{errors.email}</p>}
          </div>
          <div>
             <input
                type="password"
                name="password"
                value={formData.password}
                onChange={handleChange}
                placeholder="パスワード"
             />
             {errors.password && (
                <p style={{ color: 'red' }}>{errors.password}</p>
             )}
          </div>
       </form>
    );
 }


送信時バリデーション

送信時にバリデーションを実行するパターンでは、onSubmit ハンドラ内で e.preventDefault() を呼び出してページのリロードを防ぎ、バリデーションを実行する。
また、touched 状態を管理することにより、ユーザが1度もフォーカスしていないフィールドにはエラーを表示しないよう制御できる。

 import { useState } from 'react';
 
 interface FormData {
    username: string;
    email: string;
    password: string;
 }
 
 interface FormErrors {
    [key: string]: string;
 }
 
 export default function SubmitValidationForm() {
    const [formData, setFormData] = useState<FormData>({
       username: '',
       email: '',
       password: '',
    });
 
    const [errors, setErrors] = useState<FormErrors>({});
    // touched: ユーザが一度フォーカスしたフィールドを記録する
    const [touched, setTouched] = useState<{ [key: string]: boolean }>({});
 
    const validateForm = (data: FormData): FormErrors => {
       const errors: FormErrors = {};
 
       if (!data.username.trim()) {
          errors.username = 'ユーザ名は必須です';
       }
       else if (data.username.length < 3) {
          errors.username = 'ユーザ名は3文字以上である必要があります';
       }

       if (!data.email.trim()) {
          errors.email = 'メールアドレスは必須です';
       } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
          errors.email = '有効なメールアドレスを入力してください';
       }
 
       if (!data.password) {
          errors.password = 'パスワードは必須です';
       }
       else if (data.password.length < 8) {
          errors.password = 'パスワードは8文字以上である必要があります';
       }
 
       return errors;
    };
 
    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
       const { name, value } = e.target;
       setFormData(prev => ({ ...prev, [name]: value }));
    };
 
    // フォーカスが外れた時点でtouchedを更新
    const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
       const { name } = e.target;
       setTouched(prev => ({ ...prev, [name]: true }));
    };
 
    const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
       // ページのリロードを防ぐ
       e.preventDefault();
 
       const newErrors = validateForm(formData);
       setErrors(newErrors);
 
       if (Object.keys(newErrors).length === 0) {
          // バリデーション通過時にAPIへ送信
          console.log('フォーム送信:', formData);
       }
    };
 
    return (
       <form onSubmit={handleSubmit}>
          <div>
             <input
                type="text"
                name="username"
                value={formData.username}
                onChange={handleChange}
                onBlur={handleBlur}
                placeholder="ユーザ名"
             />
             {/* touchedかつエラーがある場合のみ表示 */}
             {touched.username && errors.username && (
                <p style={{ color: 'red' }}>{errors.username}</p>
             )}
          </div>
          <div>
             <input
                type="email"
                name="email"
                value={formData.email}
                onChange={handleChange}
                onBlur={handleBlur}
                placeholder="メール"
             />
             {touched.email && errors.email && (
                <p style={{ color: 'red' }}>{errors.email}</p>
             )}
          </div>
          <div>
             <input
                type="password"
                name="password"
                value={formData.password}
                onChange={handleChange}
                onBlur={handleBlur}
                placeholder="パスワード"
             />
             {touched.password && errors.password && (
                <p style={{ color: 'red' }}>{errors.password}</p>
             )}
          </div>
          <button
             type="submit"
             disabled={Object.keys(errors).length > 0}
          >
             送信
          </button>
       </form>
    );
 }


下表に、バリデーションパターンを示す。

バリデーションパターンの比較
パターン タイミング メリット デメリット
リアルタイムバリデーション onChange発生時 即時フィードバックで入力ミスをすぐに検知できる 入力途中でもエラーが表示されるため、UXが低下する場合がある
送信時バリデーション onSubmit発生時 入力が完了してからエラーを表示するためUXが向上する エラーに気付くのが遅くなる
touched + onBlur フォーカスが外れた時 フィールドを離れた後にのみエラーを表示する
現実的なUX
touched状態の管理が必要になる



関連情報