Chapter 08

フォームを作ろう

Reactでフォームを手作りします。入力値の管理、バリデーション、エラー表示を自前で実装することで、フォームの本質的な仕組みと、次のチャプターで紹介するライブラリの価値を体感します。

70 min

このチャプターで学ぶこと

  • 制御コンポーネント(Controlled Component)の概念を理解できる
  • 複数のStateを使ってフォームの入力値を管理できる
  • 手動バリデーションのロジックを実装できる
  • エラーメッセージを適切に表示できる
  • フォームの送信処理を実装できる
  • 手動実装の課題(ボイラープレートの多さ)を実感できる

フォームを作ろう

さあ、いよいよ実践的なUIの登場です。Webアプリを作るうえで、フォームは避けて通れません。ユーザー登録、ログイン、お問い合わせ……あらゆる場面でフォームが必要です。

このチャプターでは、あえてライブラリを使わずに、Reactのプリミティブな機能だけで登録フォームを作り切ります。「なぜあえて?」と思いますか?次のチャプターで紹介するライブラリの価値を、骨身に染みて理解してもらうためです。

まずは、苦労を味わいましょう。

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

HTMLのフォームをそのままJSXに書いても、入力値はReactのStateとは連携していません。ReactでフォームUIを扱う基本的なパターンが 制御コンポーネント(Controlled Component) です。

考え方はシンプルです:

  1. 入力値をStateで管理する
  2. <input>value にそのStateを渡す
  3. onChange でStateを更新する

これでReactが入力値を完全にコントロールします。

import { useState } from 'react';

function NameInput() {
  // 入力値をStateで管理する
  const [name, setName] = useState('');

  return (
    <div>
      <input
        type="text"
        value={name}        // StateをValueに渡す
        onChange={(e) => setName(e.target.value)} // 変更があればStateを更新
        placeholder="お名前を入力"
      />
      <p>入力中: {name}</p>
    </div>
  );
}

e.target.value は、変更されたinput要素の現在の値です。これをStateにセットすることで、Reactの世界に入力値を取り込めます。

📝 Laravelで例えると

Laravelには old('field_name') というヘルパー関数があります。バリデーションエラーでリダイレクトされた後も、ユーザーが入力した値をフォームに表示し続けるための仕組みです。

<input type="text" name="name" value="{{ old('name') }}">

Reactの制御コンポーネントも本質的には同じです。「Stateに保存されている値をフォームに表示する」というサイクルを常に回しています。ただし、Laravelのようにリクエスト→レスポンスのサイクルを経る必要はなく、リアルタイムに同期されます。

なぜ valueonChange をセットで書くのか

value だけを渡して onChange を書かないと、Reactからこんな警告が出ます:

Warning: You provided a value prop to a form field without an onChange handler.

value をStateに固定した時点で、ユーザーがタイプしてもReactは「いや、値はStateが決めている」と判断して画面に反映しません。だからStateを更新する onChange が必ずセットで必要になります。これが「制御(Controlled)」の意味です。

登録フォームを作る

それでは本題に入りましょう。以下の4つのフィールドを持つ登録フォームを手作りします:

Step 1: フィールドごとにStateを用意する

まず、4つのフィールドそれぞれのStateを用意します。

import { useState } from 'react';

function RegistrationForm() {
  // フィールドごとにStateを宣言する
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');

  return (
    <form>
      <div>
        <label>名前</label>
        <input
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
      </div>

      <div>
        <label>メールアドレス</label>
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
      </div>

      <div>
        <label>パスワード</label>
        <input
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>

      <div>
        <label>パスワード(確認)</label>
        <input
          type="password"
          value={confirmPassword}
          onChange={(e) => setConfirmPassword(e.target.value)}
        />
      </div>

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

4つのフィールドで、もう4つの useState、4つの onChange ハンドラーが必要です。フィールドが増えるほど、このリストは長くなります。

localhost:5173
空の登録フォーム
フィールドが4つあるシンプルな登録フォーム。見た目はシンプルでも、裏側のStateはすでに4つある。

Step 2: バリデーションのStateを追加する

次に、エラーメッセージを管理するStateを追加します。各フィールドに対して、エラー文字列を保持するStateが必要です。

function RegistrationForm() {
  // 入力値のState(4つ)
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');

  // エラーメッセージのState(4つ追加)
  const [nameError, setNameError] = useState('');
  const [emailError, setEmailError] = useState('');
  const [passwordError, setPasswordError] = useState('');
  const [confirmPasswordError, setConfirmPasswordError] = useState('');

  // ...
}

4フィールドの登録フォームを作るだけで、もう 8つの useState が並びました。これは少し多いと感じませんか?まだバリデーションロジックにも送信処理にも入っていません。

Step 3: バリデーションロジックを実装する

バリデーションは手動で書く必要があります。条件ごとにチェックして、エラー文字列をセットします。

function validate() {
  // バリデーション結果を追跡するフラグ
  let isValid = true;

  // 名前のバリデーション
  if (!name.trim()) {
    setNameError('名前を入力してください');
    isValid = false;
  } else {
    setNameError(''); // エラーがなければクリアする
  }

  // メールアドレスのバリデーション
  if (!email.trim()) {
    setEmailError('メールアドレスを入力してください');
    isValid = false;
  } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    // 正規表現でメール形式をチェック
    setEmailError('有効なメールアドレスを入力してください');
    isValid = false;
  } else {
    setEmailError('');
  }

  // パスワードのバリデーション
  if (!password) {
    setPasswordError('パスワードを入力してください');
    isValid = false;
  } else if (password.length < 8) {
    setPasswordError('パスワードは8文字以上にしてください');
    isValid = false;
  } else {
    setPasswordError('');
  }

  // パスワード確認のバリデーション
  if (!confirmPassword) {
    setConfirmPasswordError('パスワード(確認)を入力してください');
    isValid = false;
  } else if (password !== confirmPassword) {
    setConfirmPasswordError('パスワードが一致しません');
    isValid = false;
  } else {
    setConfirmPasswordError('');
  }

  return isValid;
}

各フィールドに対して「空チェック → 形式チェック → エラーをセット or クリア」という同じパターンを繰り返しています。4フィールドだとこの長さですが、10フィールドのフォームではどうなるでしょう…。

Step 4: 送信処理をつなぐ

フォームの onSubmit でバリデーションを実行し、パスすれば送信処理に進みます。

// 送信成功フラグ
const [isSubmitted, setIsSubmitted] = useState(false);

function handleSubmit(e) {
  // デフォルトのHTMLフォーム送信(ページリロード)を止める
  e.preventDefault();

  // バリデーションを実行する
  const isValid = validate();

  if (isValid) {
    // バリデーションが通ったら送信処理
    console.log('送信するデータ:', { name, email, password });
    setIsSubmitted(true);
  }
}
📝 e.preventDefault() について

HTMLのフォームは submit イベントが発火するとデフォルトでページをリロードします。SPAのReactではそれは困るので e.preventDefault() でデフォルト動作をキャンセルします。LaravelのAjax送信で event.preventDefault() を呼ぶのと同じです。

Step 5: エラーメッセージを表示する

最後に、JSXにエラーメッセージの表示を追加します。

<div>
  <label>名前</label>
  <input
    type="text"
    value={name}
    onChange={(e) => setName(e.target.value)}
  />
  {/* エラーがあれば表示する */}
  {nameError && <p style={{ color: 'red' }}>{nameError}</p>}
</div>

これをすべてのフィールドに追加します。

完成版コード

ここまでの内容を一つにまとめると、こうなります。覚悟して読んでください。

import { useState } from 'react';

function RegistrationForm() {
  // =====================
  // 入力値のState(4つ)
  // =====================
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');

  // =====================
  // エラーのState(4つ)
  // =====================
  const [nameError, setNameError] = useState('');
  const [emailError, setEmailError] = useState('');
  const [passwordError, setPasswordError] = useState('');
  const [confirmPasswordError, setConfirmPasswordError] = useState('');

  // 送信成功フラグ
  const [isSubmitted, setIsSubmitted] = useState(false);

  // =====================
  // バリデーション関数
  // =====================
  function validate() {
    let isValid = true;

    if (!name.trim()) {
      setNameError('名前を入力してください');
      isValid = false;
    } else {
      setNameError('');
    }

    if (!email.trim()) {
      setEmailError('メールアドレスを入力してください');
      isValid = false;
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      setEmailError('有効なメールアドレスを入力してください');
      isValid = false;
    } else {
      setEmailError('');
    }

    if (!password) {
      setPasswordError('パスワードを入力してください');
      isValid = false;
    } else if (password.length < 8) {
      setPasswordError('パスワードは8文字以上にしてください');
      isValid = false;
    } else {
      setPasswordError('');
    }

    if (!confirmPassword) {
      setConfirmPasswordError('パスワード(確認)を入力してください');
      isValid = false;
    } else if (password !== confirmPassword) {
      setConfirmPasswordError('パスワードが一致しません');
      isValid = false;
    } else {
      setConfirmPasswordError('');
    }

    return isValid;
  }

  // =====================
  // 送信ハンドラー
  // =====================
  function handleSubmit(e) {
    e.preventDefault();
    const isValid = validate();
    if (isValid) {
      console.log('送信データ:', { name, email, password });
      setIsSubmitted(true);
    }
  }

  // 送信成功時のメッセージ
  if (isSubmitted) {
    return <p>登録が完了しました!ようこそ、{name}さん!</p>;
  }

  // =====================
  // JSX
  // =====================
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>名前</label>
        <input
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
        {nameError && <p style={{ color: 'red' }}>{nameError}</p>}
      </div>

      <div>
        <label>メールアドレス</label>
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
        {emailError && <p style={{ color: 'red' }}>{emailError}</p>}
      </div>

      <div>
        <label>パスワード</label>
        <input
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
        {passwordError && <p style={{ color: 'red' }}>{passwordError}</p>}
      </div>

      <div>
        <label>パスワード(確認)</label>
        <input
          type="password"
          value={confirmPassword}
          onChange={(e) => setConfirmPassword(e.target.value)}
        />
        {confirmPasswordError && (
          <p style={{ color: 'red' }}>{confirmPasswordError}</p>
        )}
      </div>

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

export default RegistrationForm;
localhost:5173
バリデーションエラーが表示された登録フォーム
何も入力せずに送信ボタンを押すと、各フィールドの下にエラーメッセージが表示される。
localhost:5173
登録成功メッセージ
バリデーションが通ると、フォームが消えて成功メッセージが表示される。

完成!でも…振り返ってみよう

動くものが作れました。おめでとうございます。でも正直なところを言えば、このコードには問題がたくさんあります。一緒に振り返りましょう。

問題1: useState が多すぎる

4フィールドのフォームで 9つuseState を書きました:

フィールドが10個になったら、useState は20個近くになります。コンポーネントの先頭だけでスクロールが必要になる日も近い。

問題2: onChange ハンドラーが毎回同じパターン

onChange={(e) => setName(e.target.value)}
onChange={(e) => setEmail(e.target.value)}
onChange={(e) => setPassword(e.target.value)}
onChange={(e) => setConfirmPassword(e.target.value)}

これは コピー&ペーストの繰り返し です。フィールドが増えるたびに同じパターンを書き続けることになります。

問題3: バリデーションロジックが長い

4フィールドのバリデーション関数が40行以上になりました。エラーのセットとクリアを両方書かないといけないし、「このフィールドにはどんなルールがあったっけ」をコードから読み解くのも一苦労です。

問題4: 型チェックがない

password.length < 8 のような条件を自分で書いているということは、バリデーションルールの定義とロジックが一体化しています。「8文字以上」というルールを変えたい場合は、バリデーション関数を探してコードを書き換える必要があります。

⚠️ このコードは「動く」が「良くはない」

このチャプターで作ったコードは学習目的で意図的にプリミティブな書き方をしています。実際のプロジェクトでこのアプローチをそのまま使うことは推奨しません。次のチャプターで、この問題を全部まとめて解決する方法を紹介します。

フィールドが10個になったら?

想像してみてください。住所入力フォームを作るとします:

10フィールドです。useState は最低20個。バリデーション関数は100行超え。onChange は10個のコピー&ペーストライン。

…正直、作りたくないですよね。

この痛みをしっかり覚えておいてください。 次のチャプターで、この問題をエレガントに解決します。

✍ やってみよう

演習: 登録フォームを完成させる

このチャプターで学んだ内容を元に、登録フォームを自分の手で1から実装してみましょう。

要件

フィールド:

  • 名前(必須、1文字以上)
  • メールアドレス(必須、メール形式)
  • パスワード(必須、8文字以上)
  • パスワード確認(必須、パスワードと一致すること)

バリデーション:

  • 送信ボタンを押したタイミングでバリデーションを実行すること
  • エラーがある場合は各フィールドの下にエラーメッセージを表示すること
  • すべてのバリデーションが通った場合のみ、console.log でデータを出力すること

成功時:

  • フォームの代わりに「登録が完了しました!」というメッセージを表示すること

ヒント

  1. まず useState を全部宣言してしまう
  2. JSXのフォーム骨格を書く(バリデーションなし)
  3. バリデーション関数を別で書いてテストする
  4. onSubmit でつなぐ

チェックリスト

  • 4つのフィールドが表示されている
  • 空のまま送信するとすべてのフィールドにエラーが表示される
  • 不正なメール形式でエラーが出る
  • 7文字以下のパスワードでエラーが出る
  • パスワードが一致しないとエラーが出る
  • 正しく入力して送信すると成功メッセージが表示される

完成したら必ずコードの行数を数えてみてください。 次のチャプターで同じフォームを何行で書けるか、楽しみにしていてください。

まとめ

このチャプターでは、Reactのプリミティブな機能だけで登録フォームを作りました。

学んだこと:

そして感じたこと:

この経験があってこそ、次のチャプターの価値がわかります。React のフォームライブラリは、まさにこの苦労を解消するために生まれました。準備はいいですか?