第10章: フォーム

Web アプリケーションにおいてフォームはユーザーとのデータのやり取りの中核です。 React では、フォームの入力値を State で管理する制御コンポーネントと、 DOM に値の管理を任せる非制御コンポーネントの 2 つのアプローチがあります。 この章では、それぞれの使い方とバリデーションの実装パターンを解説します。

React のフォーム処理

HTML の標準フォーム要素(<input><textarea><select>)は 自身で内部状態を管理しています。しかし React では、コンポーネントの State を 「信頼できる唯一の情報源(Single Source of Truth)」として管理するのが一般的です。

React のフォーム処理では、ユーザーの入力をリアルタイムで State に反映し、 State の値をフォーム要素に渡すことで、データの流れを一方向に保ちます。

制御コンポーネント

制御コンポーネント(Controlled Component)とは、 フォーム要素の値を React の State で管理するパターンです。 value 属性に State を、onChange ハンドラで State を更新します。

基本的な制御コンポーネント
import { useState } from 'react';

function NameInput() {
  const [name, setName] = useState('');

  function handleChange(e) {
    setName(e.target.value);
  }

  return (
    <div>
      <label>
        名前:
        <input
          type="text"
          value={name}
          onChange={handleChange}
        />
      </label>
      <p>入力値: {name}</p>
    </div>
  );
}

データの流れは以下の通りです。

  1. ユーザーがキーボードで文字を入力する
  2. onChange イベントが発火する
  3. ハンドラが setName(e.target.value) で State を更新する
  4. State の変更により再レンダーが発生する
  5. value={name} により、input に最新の値が表示される

複数のフォーム要素を管理

フォームに複数の入力欄がある場合、入力欄ごとに State を定義するか、 オブジェクトで一括管理する方法があります。

オブジェクトで一括管理
import { useState } from 'react';

function SignupForm() {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: '',
  });

  function handleChange(e) {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value,  // computed property name
    }));
  }

  function handleSubmit(e) {
    e.preventDefault();
    console.log('送信データ:', formData);
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>ユーザー名:</label>
        <input name="username" value={formData.username} onChange={handleChange} />
      </div>
      <div>
        <label>メール:</label>
        <input name="email" type="email" value={formData.email} onChange={handleChange} />
      </div>
      <div>
        <label>パスワード:</label>
        <input name="password" type="password" value={formData.password} onChange={handleChange} />
      </div>
      <button type="submit">登録</button>
    </form>
  );
}

ポイント: [name]: value は JavaScript の計算プロパティ名 (Computed Property Name) 構文です。 各 <input>name 属性を State のキーと一致させることで、 1 つのハンドラで複数の入力欄を処理できます。 ...prev でスプレッドして既存の値を保持しつつ、変更されたフィールドだけを更新します。

さまざまな入力要素

textarea

HTML では <textarea> の値は子要素として記述しますが、 React では value 属性を使います。

textarea
const [comment, setComment] = useState('');

<textarea
  value={comment}
  onChange={(e) => setComment(e.target.value)}
  rows={5}
/>

select

select
const [category, setCategory] = useState('general');

<select value={category} onChange={(e) => setCategory(e.target.value)}>
  <option value="general">一般</option>
  <option value="tech">技術</option>
  <option value="support">サポート</option>
</select>

checkbox

チェックボックスは value ではなく checked 属性を使います。

checkbox
const [agreed, setAgreed] = useState(false);

<label>
  <input
    type="checkbox"
    checked={agreed}
    onChange={(e) => setAgreed(e.target.checked)}
  />
  利用規約に同意する
</label>

radio

radio
const [color, setColor] = useState('red');

<div>
  <label>
    <input
      type="radio"
      name="color"
      value="red"
      checked={color === 'red'}
      onChange={(e) => setColor(e.target.value)}
    /></label>
  <label>
    <input
      type="radio"
      name="color"
      value="blue"
      checked={color === 'blue'}
      onChange={(e) => setColor(e.target.value)}
    /></label>
</div>
要素値の属性イベントで取得する値
input[type="text"]valuee.target.value
textareavaluee.target.value
selectvaluee.target.value
input[type="checkbox"]checkede.target.checked
input[type="radio"]checkede.target.value

非制御コンポーネント

非制御コンポーネント(Uncontrolled Component)は、 フォームの値を React の State ではなく DOM 自身に管理させるパターンです。 useRef を使って DOM 要素を参照し、必要なとき(送信時など)に値を取得します。

非制御コンポーネント
import { useRef } from 'react';

function FileUpload() {
  const fileInputRef = useRef(null);

  function handleSubmit(e) {
    e.preventDefault();
    const file = fileInputRef.current.files[0];
    console.log('選択されたファイル:', file.name);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="file" ref={fileInputRef} />
      <button type="submit">アップロード</button>
    </form>
  );
}

<input type="file"> は常に非制御コンポーネントです。 セキュリティ上の理由から、JavaScript でファイル入力の値をプログラムで設定することはできません。

制御 vs 非制御

比較項目制御コンポーネント非制御コンポーネント
値の管理場所 React の State DOM
値の取得方法 State 変数を参照 ref.current.value
リアルタイム検証 容易(入力のたびに検証可能) 困難
入力値の加工 容易(大文字変換、フォーマットなど) 困難
動的な UI 変更 容易(入力に応じて UI を変更) 困難
コード量 やや多い 少ない
適した場面 ほとんどのケース ファイル入力、シンプルなフォーム

迷ったら制御コンポーネントを使いましょう。 React 公式ドキュメントでも制御コンポーネントが推奨されています。 非制御コンポーネントは、ファイル入力や、React 以外のライブラリと統合する場合に限って使うのがベストプラクティスです。

バリデーション

制御コンポーネントを使うと、ユーザーの入力をリアルタイムに検証できます。 エラーメッセージを State で管理し、条件に応じて表示を切り替えます。

リアルタイムバリデーション
import { useState } from 'react';

function ValidatedInput() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');

  function handleChange(e) {
    const value = e.target.value;
    setEmail(value);

    // リアルタイムバリデーション
    if (value && !value.includes('@')) {
      setError('有効なメールアドレスを入力してください');
    } else {
      setError('');
    }
  }

  return (
    <div>
      <label>メールアドレス:</label>
      <input
        type="email"
        value={email}
        onChange={handleChange}
      />
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
}

送信時バリデーション

送信時の一括バリデーション
import { useState } from 'react';

function RegistrationForm() {
  const [formData, setFormData] = useState({
    name: '', email: '', password: '',
  });
  const [errors, setErrors] = useState({});

  function validate() {
    const newErrors = {};

    if (!formData.name.trim()) {
      newErrors.name = '名前は必須です';
    }

    if (!formData.email.includes('@')) {
      newErrors.email = '有効なメールアドレスを入力してください';
    }

    if (formData.password.length < 8) {
      newErrors.password = 'パスワードは8文字以上にしてください';
    }

    return newErrors;
  }

  function handleChange(e) {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
    // 入力時にそのフィールドのエラーをクリア
    setErrors(prev => ({ ...prev, [name]: undefined }));
  }

  function handleSubmit(e) {
    e.preventDefault();
    const newErrors = validate();

    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      return;
    }

    console.log('送信成功:', formData);
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>名前:</label>
        <input name="name" value={formData.name} onChange={handleChange} />
        {errors.name && <p style={{ color: 'red' }}>{errors.name}</p>}
      </div>
      <div>
        <label>メール:</label>
        <input name="email" type="email" value={formData.email} onChange={handleChange} />
        {errors.email && <p style={{ color: 'red' }}>{errors.email}</p>}
      </div>
      <div>
        <label>パスワード:</label>
        <input name="password" type="password" value={formData.password} onChange={handleChange} />
        {errors.password && <p style={{ color: 'red' }}>{errors.password}</p>}
      </div>
      <button type="submit">登録</button>
    </form>
  );
}

フォームの送信

React でのフォーム送信では、onSubmit イベントを使い、 e.preventDefault() で HTML 標準のフォーム送信(ページリロード)を防止します。 その後、JavaScript で API にデータを送信します。

フォーム送信パターン
import { useState } from 'react';

function SubmitForm() {
  const [name, setName] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const [result, setResult] = useState(null);

  async function handleSubmit(e) {
    e.preventDefault();
    setSubmitting(true);

    try {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name }),
      });

      if (!response.ok) throw new Error('送信に失敗しました');
      setResult('登録が完了しました!');
      setName('');  // フォームをリセット
    } catch (err) {
      setResult(`エラー: ${err.message}`);
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        disabled={submitting}
      />
      <button type="submit" disabled={submitting}>
        {submitting ? '送信中...' : '送信'}
      </button>
      {result && <p>{result}</p>}
    </form>
  );
}

送信中の二重送信を防ぐ: submitting State で送信中フラグを管理し、ボタンと入力欄を disabled にすることで、ユーザーが連打して二重送信するのを防ぎます。

実践例: お問い合わせフォーム

ここまで学んだ内容を総合した実践的なお問い合わせフォームの例です。 複数フィールドの管理、バリデーション、送信処理、結果表示をすべて含んでいます。

ContactForm.jsx
import { useState } from 'react';

const INITIAL_STATE = {
  name: '',
  email: '',
  category: 'general',
  message: '',
  agreed: false,
};

function ContactForm() {
  const [formData, setFormData] = useState(INITIAL_STATE);
  const [errors, setErrors] = useState({});
  const [submitting, setSubmitting] = useState(false);
  const [submitted, setSubmitted] = useState(false);

  function handleChange(e) {
    const { name, value, type, checked } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: type === 'checkbox' ? checked : value,
    }));
    setErrors(prev => ({ ...prev, [name]: undefined }));
  }

  function validate() {
    const errs = {};
    if (!formData.name.trim())     errs.name = '名前を入力してください';
    if (!formData.email.includes('@')) errs.email = '有効なメールアドレスを入力してください';
    if (!formData.message.trim())  errs.message = 'メッセージを入力してください';
    if (!formData.agreed)         errs.agreed = '利用規約への同意が必要です';
    return errs;
  }

  async function handleSubmit(e) {
    e.preventDefault();
    const newErrors = validate();
    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      return;
    }

    setSubmitting(true);
    try {
      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(formData),
      });
      if (!response.ok) throw new Error('送信に失敗しました');
      setSubmitted(true);
      setFormData(INITIAL_STATE);
    } catch (err) {
      setErrors({ submit: err.message });
    } finally {
      setSubmitting(false);
    }
  }

  if (submitted) {
    return <p>お問い合わせを送信しました。ありがとうございます!</p>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>お名前 <span style={{ color: 'red' }}>*</span></label>
        <input name="name" value={formData.name} onChange={handleChange} />
        {errors.name && <p style={{ color: 'red' }}>{errors.name}</p>}
      </div>

      <div>
        <label>メール <span style={{ color: 'red' }}>*</span></label>
        <input name="email" type="email" value={formData.email} onChange={handleChange} />
        {errors.email && <p style={{ color: 'red' }}>{errors.email}</p>}
      </div>

      <div>
        <label>カテゴリ</label>
        <select name="category" value={formData.category} onChange={handleChange}>
          <option value="general">一般</option>
          <option value="tech">技術</option>
          <option value="support">サポート</option>
        </select>
      </div>

      <div>
        <label>メッセージ <span style={{ color: 'red' }}>*</span></label>
        <textarea name="message" rows={5} value={formData.message} onChange={handleChange} />
        {errors.message && <p style={{ color: 'red' }}>{errors.message}</p>}
      </div>

      <div>
        <label>
          <input name="agreed" type="checkbox" checked={formData.agreed} onChange={handleChange} />
          利用規約に同意する
        </label>
        {errors.agreed && <p style={{ color: 'red' }}>{errors.agreed}</p>}
      </div>

      {errors.submit && <p style={{ color: 'red' }}>{errors.submit}</p>}

      <button type="submit" disabled={submitting}>
        {submitting ? '送信中...' : '送信'}
      </button>
    </form>
  );
}

export default ContactForm;

大規模なフォームを管理する場合は、React Hook FormFormik などのフォームライブラリの利用を検討しましょう。 バリデーション、エラー管理、パフォーマンスの最適化を効率的に行えます。