Reactのフォーム処理:Controlled Components

2025-08-08

Reactにおけるフォーム処理は、HTMLフォームとは異なるアプローチが必要です。この章では、Reactの「Controlled Components(制御されたコンポーネント)」という概念を中心に、フォーム処理の基本から実践的な使い方までを詳細に解説します。

Controlled Componentsの基本概念

Controlled vs Uncontrolled

Reactのフォーム処理には2つのアプローチがあります:

  1. Controlled Components(制御されたコンポーネント)
  • Reactがフォームデータを完全に管理
  • 入力値は常にReactのstateと同期
  • 推奨される方法
  1. Uncontrolled Components(非制御コンポーネント)
  • 伝統的なHTMLフォームのようにDOMがデータを管理
  • refを使用して必要な時に値を取得

Controlled Componentsの仕組み

Controlled Componentsでは、以下のサイクルで動作します:

  1. フォーム入力が発生
  2. onChangeイベントハンドラーが呼び出される
  3. ハンドラーがstateを更新
  4. コンポーネントが再レンダリング
  5. 入力値が更新されたstateと同期
import { useState } from 'react';

function ControlledForm() {
  const [value, setValue] = useState('');

  const handleChange = (event) => {
    setValue(event.target.value);
  };

  const handleSubmit = (event) => {
    event.preventDefault();
    alert(`入力値: ${value}`);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        名前:
        <input type="text" value={value} onChange={handleChange} />
      </label>
      <button type="submit">送信</button>
    </form>
  );
}

基本的なフォーム要素の扱い方

テキスト入力(input type=”text”)

function TextInput() {
  const [text, setText] = useState('');

  return (
    <input
      type="text"
      value={text}
      onChange={(e) => setText(e.target.value)}
      placeholder="テキストを入力"
    />
  );
}

テキストエリア

function TextAreaInput() {
  const [text, setText] = useState('');

  return (
    <textarea
      value={text}
      onChange={(e) => setText(e.target.value)}
      placeholder="複数行のテキストを入力"
    />
  );
}

セレクトボックス

function SelectBox() {
  const [selectedOption, setSelectedOption] = useState('option1');

  return (
    <select
      value={selectedOption}
      onChange={(e) => setSelectedOption(e.target.value)}
    >
      <option value="option1">オプション1</option>
      <option value="option2">オプション2</option>
      <option value="option3">オプション3</option>
    </select>
  );
}

チェックボックス

function CheckboxExample() {
  const [isChecked, setIsChecked] = useState(false);

  return (
    <label>
      <input
        type="checkbox"
        checked={isChecked}
        onChange={(e) => setIsChecked(e.target.checked)}
      />
      同意する
    </label>
  );
}

ラジオボタン

function RadioButtons() {
  const [selectedOption, setSelectedOption] = useState('option1');

  return (
    <div>
      <label>
        <input
          type="radio"
          value="option1"
          checked={selectedOption === 'option1'}
          onChange={(e) => setSelectedOption(e.target.value)}
        />
        オプション1
      </label>
      <label>
        <input
          type="radio"
          value="option2"
          checked={selectedOption === 'option2'}
          onChange={(e) => setSelectedOption(e.target.value)}
        />
        オプション2
      </label>
    </div>
  );
}

複数入力フォームの管理

個別のstateを使用する方法

function MultiInputForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [age, setAge] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log({ name, email, age });
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        名前:
        <input
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
      </label>
      <label>
        メールアドレス:
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
      </label>
      <label>
        年齢:
        <input
          type="number"
          value={age}
          onChange={(e) => setAge(e.target.value)}
        />
      </label>
      <button type="submit">送信</button>
    </form>
  );
}

単一のstateオブジェクトを使用する方法

function UnifiedStateForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    age: ''
  });

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

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log(formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        名前:
        <input
          type="text"
          name="name"
          value={formData.name}
          onChange={handleChange}
        />
      </label>
      <label>
        メールアドレス:
        <input
          type="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
        />
      </label>
      <label>
        年齢:
        <input
          type="number"
          name="age"
          value={formData.age}
          onChange={handleChange}
        />
      </label>
      <button type="submit">送信</button>
    </form>
  );
}

フォームバリデーション

基本的なバリデーション

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

  const validate = () => {
    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文字以上必要です';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

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

  const handleSubmit = (e) => {
    e.preventDefault();
    if (validate()) {
      console.log('フォームが送信されました:', formData);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>メールアドレス:</label>
        <input
          type="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
        />
        {errors.email && <span style={{ color: 'red' }}>{errors.email}</span>}
      </div>
      <div>
        <label>パスワード:</label>
        <input
          type="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
        />
        {errors.password && <span style={{ color: 'red' }}>{errors.password}</span>}
      </div>
      <button type="submit">ログイン</button>
    </form>
  );
}

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

function RealTimeValidation() {
  const [formData, setFormData] = useState({
    username: '',
    email: ''
  });
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  const validateField = (name, value) => {
    let error = '';

    if (name === 'username') {
      if (!value) error = 'ユーザー名は必須です';
      else if (value.length < 3) error = '3文字以上必要です';
    }

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

    return error;
  };

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

    if (touched[name]) {
      setErrors(prev => ({
        ...prev,
        [name]: validateField(name, value)
      }));
    }
  };

  const handleBlur = (e) => {
    const { name, value } = e.target;
    setTouched(prev => ({ ...prev, [name]: true }));
    setErrors(prev => ({
      ...prev,
      [name]: validateField(name, value)
    }));
  };

  const handleSubmit = (e) => {
    e.preventDefault();

    // 全てのフィールドを検証
    const newErrors = {};
    Object.keys(formData).forEach(name => {
      newErrors[name] = validateField(name, formData[name]);
    });
    setErrors(newErrors);

    // エラーがない場合のみ送信
    if (Object.values(newErrors).every(error => !error)) {
      console.log('フォームが送信されました:', formData);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>ユーザー名:</label>
        <input
          type="text"
          name="username"
          value={formData.username}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {errors.username && <span style={{ color: 'red' }}>{errors.username}</span>}
      </div>
      <div>
        <label>メールアドレス:</label>
        <input
          type="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {errors.email && <span style={{ color: 'red' }}>{errors.email}</span>}
      </div>
      <button type="submit">登録</button>
    </form>
  );
}

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

カスタムフックの作成

複数のフォームで同じロジックを再利用するために、カスタムフックを作成できます。

function useForm(initialValues, validate) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  const handleChange = (e) => {
    const { name, value } = e.target;
    setValues(prev => ({
      ...prev,
      [name]: value
    }));

    if (touched[name]) {
      setErrors(prev => ({
        ...prev,
        [name]: validate[name](value)
      }));
    }
  };

  const handleBlur = (e) => {
    const { name, value } = e.target;
    setTouched(prev => ({ ...prev, [name]: true }));
    setErrors(prev => ({
      ...prev,
      [name]: validate[name](value)
    }));
  };

  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    setValues,
    setErrors,
    setTouched
  };
}

// 使用例
function UserForm() {
  const { values, errors, touched, handleChange, handleBlur } = useForm(
    { username: '', email: '' },
    {
      username: (value) => {
        if (!value) return 'ユーザー名は必須です';
        if (value.length < 3) return '3文字以上必要です';
        return '';
      },
      email: (value) => {
        if (!value) return 'メールアドレスは必須です';
        if (!/^\S+@\S+\.\S+$/.test(value)) return '有効なメールアドレスを入力してください';
        return '';
      }
    }
  );

  const handleSubmit = (e) => {
    e.preventDefault();
    // フォーム送信処理
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* フォームフィールド */}
    </form>
  );
}

パフォーマンス最適化

大きなフォームでは、各入力ごとにコンポーネント全体が再レンダリングされるのを防ぐために、個々の入力フィールドをメモ化できます。

const MemoizedInput = React.memo(function Input({ label, name, value, error, touched, onChange, onBlur }) {
  return (
    <div>
      <label>{label}</label>
      <input
        type="text"
        name={name}
        value={value}
        onChange={onChange}
        onBlur={onBlur}
      />
      {touched && error && <span style={{ color: 'red' }}>{error}</span>}
    </div>
  );
});

function OptimizedForm() {
  // ...フォームロジック...

  return (
    <form onSubmit={handleSubmit}>
      <MemoizedInput
        label="ユーザー名"
        name="username"
        value={values.username}
        error={errors.username}
        touched={touched.username}
        onChange={handleChange}
        onBlur={handleBlur}
      />
      {/* 他のフィールド */}
    </form>
  );
}

サードパーティライブラリの利用

大規模なアプリケーションでは、フォーム処理ライブラリを使用すると便利です。

Formikの基本的な使用例

import { Formik, Form, Field, ErrorMessage } from 'formik';

function FormikForm() {
  return (
    <Formik
      initialValues={{ email: '', password: '' }}
      validate={values => {
        const errors = {};
        if (!values.email) {
          errors.email = '必須項目です';
        } else if (
          !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email)
        ) {
          errors.email = '無効なメールアドレスです';
        }
        return errors;
      }}
      onSubmit={(values, { setSubmitting }) => {
        setTimeout(() => {
          alert(JSON.stringify(values, null, 2));
          setSubmitting(false);
        }, 400);
      }}
    >
      {({ isSubmitting }) => (
        <Form>
          <label htmlFor="email">メールアドレス</label>
          <Field type="email" name="email" />
          <ErrorMessage name="email" component="div" />

          <label htmlFor="password">パスワード</label>
          <Field type="password" name="password" />
          <ErrorMessage name="password" component="div" />

          <button type="submit" disabled={isSubmitting}>
            送信
          </button>
        </Form>
      )}
    </Formik>
  );
}

React Hook Formの基本的な使用例

import { useForm } from 'react-hook-form';

function HookForm() {
  const { register, handleSubmit, formState: { errors } } = useForm();
  const onSubmit = data => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label>ユーザー名</label>
      <input
        {...register("username", { required: true, minLength: 3 })}
      />
      {errors.username?.type === "required" && (
        <p>ユーザー名は必須です</p>
      )}
      {errors.username?.type === "minLength" && (
        <p>3文字以上入力してください</p>
      )}

      <label>メールアドレス</label>
      <input
        type="email"
        {...register("email", { required: true, pattern: /^\\S+@\\S+\\.\\S+$/ })}
      />
      {errors.email && <p>有効なメールアドレスを入力してください</p>}

      <button type="submit">登録</button>
    </form>
  );
}

よくある問題と解決策

パフォーマンス問題

問題:大きなフォームで入力が遅れる
解決策

  • デバウンス処理を実装
  • コンポーネントをメモ化
  • 必要なフィールドのみをレンダリング
function DebouncedInput({ value, onChange, ...props }) {
  const [internalValue, setInternalValue] = useState(value);
  const timerRef = useRef();

  useEffect(() => {
    setInternalValue(value);
  }, [value]);

  const handleChange = (e) => {
    const newValue = e.target.value;
    setInternalValue(newValue);

    clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => {
      onChange(newValue);
    }, 300);
  };

  return <input {...props} value={internalValue} onChange={handleChange} />;
}

複雑なバリデーション

問題:複数のフィールドに依存するバリデーション
解決策:フォーム全体のバリデーション関数を使用

function validateForm(values) {
  const errors = {};

  if (!values.password) {
    errors.password = 'パスワードは必須です';
  }

  if (!values.confirmPassword) {
    errors.confirmPassword = '確認用パスワードは必須です';
  } else if (values.password !== values.confirmPassword) {
    errors.confirmPassword = 'パスワードが一致しません';
  }

  return errors;
}

動的フォーム

問題:フィールド数が動的に変化する
解決策:配列ベースのstate管理

function DynamicForm() {
  const [fields, setFields] = useState([{ id: 1, value: '' }]);

  const addField = () => {
    setFields([...fields, { id: Date.now(), value: '' }]);
  };

  const removeField = (id) => {
    setFields(fields.filter(field => field.id !== id));
  };

  const handleChange = (id, value) => {
    setFields(fields.map(field =>
      field.id === id ? { ...field, value } : field
    ));
  };

  return (
    <div>
      {fields.map(field => (
        <div key={field.id}>
          <input
            value={field.value}
            onChange={(e) => handleChange(field.id, e.target.value)}
          />
          <button onClick={() => removeField(field.id)}>削除</button>
        </div>
      ))}
      <button onClick={addField}>フィールドを追加</button>
    </div>
  );
}

まとめ:Reactフォーム処理の重要なポイント

  1. Controlled Components
  • Reactがフォームデータを完全に管理
  • 入力値は常にstateと同期
  1. 基本的なフォーム要素
  • input, textarea, select, checkbox, radioなど
  • それぞれ適切なvalueプロパティとイベントハンドラーを設定
  1. バリデーション戦略
  • リアルタイムバリデーション
  • 送信時の包括的なバリデーション
  • ユーザーフレンドリーなエラーメッセージ
  1. パフォーマンス最適化
  • メモ化
  • デバウンス処理
  • 必要な部分のみの再レンダリング
  1. 大規模アプリケーション
  • カスタムフックの作成
  • FormikやReact Hook Formなどのライブラリの活用

Reactのフォーム処理は初めは複雑に感じるかもしれませんが、Controlled Componentsの概念を理解すれば、強力で柔軟なフォームを作成できます。実践を重ねることで、より洗練されたフォーム処理ができるようになります。