目次
9 章

フォームの扱い(controlled inputs)

React でフォームを扱う定石パターンである controlled component を見ていきます。textarea・select・checkbox・radio もすべて同じモデルで扱う方法まで。

8 章 まで、React の核となるビルディングブロックをすべて見ました。コンポーネント、props、state、イベント、条件付き / リストレンダリングまで、すべて扱いました。本章は 1 部のまとめです。ほぼすべてのアプリに登場するフォーム(form) の扱いを、定石パターンで固めます。

本章の controlled モデルは 19 章(イベントとフォームの型付け)で TypeScript によって固められ、27 章(Server Actions とフォーム)で新しいモデル(<form action={fn}> + useActionState)へもう一度拡張されます。本章のパターンをしっかり押さえておけば、その後の章が軽く読めます。

入力要素を扱う 2 つの方式 #

React で入力要素を扱う方式は大きく 2 つに分かれます。

  • Controlled Component(制御コンポーネント) — 入力値の基準を React の state に置き、画面の入力がその state を追うようにする方式
  • Uncontrolled Component(非制御コンポーネント) — 入力値を DOM 自体に任せ、必要なときに ref で取り出して使う方式

React では Controlled Component が定石で、本章もそこに集中します。非制御方式は 18 章(hooks の型付け)で useRef を扱うときにもう一度押さえる機会があります。

Controlled Component とは #

すでに 6 章と 8 章で使っていたパターンです。valueonChange を 1 組にまとめるのが核心です。

src/SimpleInput.jsx
import { useState } from 'react';

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

  return (
    <div>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <p>入力値: {text}</p>
    </div>
  );
}

export default SimpleInput;

流れは次のとおりです。

  1. ユーザがキーを入力 → ブラウザが change イベントを発火
  2. onChange ハンドラが e.target.value で新しい値を取り出して setText で state に反映
  3. state が変わるとコンポーネントが再レンダリングされる
  4. 新しくレンダリングされるとき <input value={text}> が更新された値を画面に表示

外から見るとただ入力したとおりに文字が見える普通の入力欄ですが、内部的には state を経由してもう一度画面に描かれるサイクルが毎回回っているのです。これが controlled component です。

なぜこんなに面倒に? #

「ブラウザが自動で入力値を覚えてくれるのに、わざわざ state で受け取って描き直す必要があるのか」と思うかもしれません。そのとおりです。そのまま置けばブラウザが値を保ってくれます(これが uncontrolled)。ところが controlled にしておくと得るものが多いです。

  • リアルタイムの加工 / 検証が楽になる(入力長さ制限、自動大文字化、形式検証など)
  • 2 つの入力欄を連動させられる(片方の変更時にもう片方を自動更新)
  • state で入力値を別のコンポーネントと共有できる(11 章で扱うパターン)
  • 送信ボタンの有効化条件を入力状態で即座に表現できる(すでに 7 章で見ましたね)

ほとんどのフォームシナリオで controlled の方が直感的で強力です。

textarea #

textarea も input と同じく value / onChange を使います。HTML では <textarea>ここにテキスト</textarea> のように子として値を入れていましたが、React では value 属性で扱います。

src/MemoForm.jsx
import { useState } from 'react';

function MemoForm() {
  const [memo, setMemo] = useState('');

  return (
    <textarea
      value={memo}
      onChange={(e) => setMemo(e.target.value)}
      rows={5}
    />
  );
}

export default MemoForm;

select #

ドロップダウン(<select>)も同じパターンです。選択されたオプションの value が e.target.value に入ってきます。

src/CategoryPicker.jsx
import { useState } from 'react';

function CategoryPicker() {
  const [category, setCategory] = useState('frontend');

  return (
    <select value={category} onChange={(e) => setCategory(e.target.value)}>
      <option value="frontend">フロントエンド</option>
      <option value="backend">バックエンド</option>
      <option value="devops">デブオプス</option>
    </select>
  );
}

export default CategoryPicker;

<option>value と state の値が対応します。初期 state('frontend')が最初に選択されたオプションになります。

checkbox #

チェックボックスは値が文字列ではなくチェック状態(boolean) です。なので value の代わりに checked を、e.target.value の代わりに e.target.checked を使います。

src/AgreeCheckbox.jsx
import { useState } from 'react';

function AgreeCheckbox() {
  const [agreed, setAgreed] = useState(false);

  return (
    <label>
      <input
        type="checkbox"
        checked={agreed}
        onChange={(e) => setAgreed(e.target.checked)}
      />
      規約に同意します
    </label>
  );
}

export default AgreeCheckbox;

radio #

ラジオボタンは複数の中から 1 つを選択するグループです。同じ name でまとめ、checkedstate === そのオプションの値 で表現します。

src/PaymentRadio.jsx
import { useState } from 'react';

function PaymentRadio() {
  const [payment, setPayment] = useState('card');

  return (
    <div>
      <label>
        <input
          type="radio"
          name="payment"
          value="card"
          checked={payment === 'card'}
          onChange={(e) => setPayment(e.target.value)}
        />
        カード
      </label>
      <label style={{ marginLeft: '12px' }}>
        <input
          type="radio"
          name="payment"
          value="bank"
          checked={payment === 'bank'}
          onChange={(e) => setPayment(e.target.value)}
        />
        銀行振込
      </label>
    </div>
  );
}

export default PaymentRadio;

複数のフィールドを 1 つのオブジェクトで管理する #

フォームフィールドが多くなると useState を何度も使うのが面倒になります。1 つのオブジェクトにまとめておくパターンもよく見られます。

src/SignupForm.jsx
import { useState } from 'react';

function SignupForm() {
  const [form, setForm] = useState({
    name: '',
    email: '',
    password: '',
    agreed: false,
  });

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

  return (
    <form>
      <input name="name" value={form.name} onChange={handleChange} placeholder="名前" />
      <input name="email" value={form.email} onChange={handleChange} placeholder="メール" />
      <input name="password" type="password" value={form.password} onChange={handleChange} placeholder="パスワード" />
      <label>
        <input name="agreed" type="checkbox" checked={form.agreed} onChange={handleChange} />
        規約に同意
      </label>
    </form>
  );
}

export default SignupForm;

核となる工夫は 2 つです。

  • 各入力要素に name 属性を与えてどのフィールドかを区別
  • setForm(prev => ({ ...prev, [name]: ... })) でオブジェクトの一部だけを更新(5 章で学んだパターン)

ハンドラ 1 つで複数のフィールドを処理できてコードがきれいです。ただしフィールドごとの検証ロジックなどが複雑になれば、再び useState を分ける方がよくなることもあります。決まった答えはなく、状況に応じて選んでください。

フォーム送信の処理 #

6 章で扱った内容の復習です。<form>onSubmit を使って、e.preventDefault() でページのリロードを防ぐのが定石です。

src/SignupForm.jsx
function handleSubmit(e) {
  e.preventDefault();
  if (!form.agreed) {
    alert('規約に同意してください。');
    return;
  }
  console.log('登録情報:', form);
  // 実際にはここでサーバに送信
}

return (
  <form onSubmit={handleSubmit}>
    {/* ... */}
    <button type="submit">登録</button>
  </form>
);

<button type="submit"> を押したり入力欄で Enter を押したりするとフォームが送信されます。ボタンの既定の typesubmit なので、明示しなくてもフォームの中では送信ボタンとして動作しますが、混乱しないよう明示する習慣を付ける方がよいです。

27 章(Server Actions とフォーム)では、<form onSubmit={...}> + e.preventDefault() + fetch('/api/...') の 3 段階が <form action={serverFn}> 1 段階にまとまる新しいモデルを扱います。本章の controlled パターンがそのモデルでもそのまま生き残ります — value / onChange の組はそのまま維持されたまま、送信だけが新しい方式に変わります。

入力値を整える #

controlled の強みの 1 つは、入力値を自由に加工できることです。set の直前に手を加えれば構いません。

自動で大文字に変換
<input
  value={code}
  onChange={(e) => setCode(e.target.value.toUpperCase())}
/>
数字のみを受け取る
<input
  value={phone}
  onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
/>
最大長さの制限
<input
  value={message}
  onChange={(e) => setMessage(e.target.value.slice(0, 100))}
/>

画面に見える値と state の値が常に一致するので、ユーザが見たそばから結果が反映されます。

自分でやってみる #

登録フォームを作ってみます。複数種類の入力要素を 1 つの画面にまとめた総合例です。

src/SignupForm.jsx:

src/SignupForm.jsx
import { useState } from 'react';

function SignupForm() {
  const [form, setForm] = useState({
    name: '',
    email: '',
    age: '',
    gender: 'female',
    interests: {
      frontend: false,
      backend: false,
      design: false,
    },
    bio: '',
    agreed: false,
  });
  const [submitted, setSubmitted] = useState(null);

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

  function handleInterestChange(e) {
    const { name, checked } = e.target;
    setForm(prev => ({
      ...prev,
      interests: { ...prev.interests, [name]: checked },
    }));
  }

  function handleSubmit(e) {
    e.preventDefault();
    if (!form.agreed) {
      alert('規約に同意してください。');
      return;
    }
    setSubmitted(form);
  }

  return (
    <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px', maxWidth: '400px' }}>
      <h2>会員登録</h2>
      <form onSubmit={handleSubmit}>
        <div>
          <label>名前: </label>
          <input name="name" value={form.name} onChange={handleChange} />
        </div>
        <div>
          <label>メール: </label>
          <input name="email" type="email" value={form.email} onChange={handleChange} />
        </div>
        <div>
          <label>年齢: </label>
          <input
            name="age"
            type="text"
            value={form.age}
            onChange={(e) => setForm(prev => ({
              ...prev,
              age: e.target.value.replace(/\D/g, ''),
            }))}
          />
        </div>
        <div>
          <label>性別: </label>
          <label>
            <input type="radio" name="gender" value="female" checked={form.gender === 'female'} onChange={handleChange} />
            女性
          </label>
          <label style={{ marginLeft: '8px' }}>
            <input type="radio" name="gender" value="male" checked={form.gender === 'male'} onChange={handleChange} />
            男性
          </label>
        </div>
        <div>
          <label>関心分野: </label>
          <label>
            <input type="checkbox" name="frontend" checked={form.interests.frontend} onChange={handleInterestChange} />
            フロントエンド
          </label>
          <label style={{ marginLeft: '8px' }}>
            <input type="checkbox" name="backend" checked={form.interests.backend} onChange={handleInterestChange} />
            バックエンド
          </label>
          <label style={{ marginLeft: '8px' }}>
            <input type="checkbox" name="design" checked={form.interests.design} onChange={handleInterestChange} />
            デザイン
          </label>
        </div>
        <div>
          <label>自己紹介: </label>
          <textarea name="bio" value={form.bio} onChange={handleChange} rows={3} />
        </div>
        <div>
          <label>
            <input type="checkbox" name="agreed" checked={form.agreed} onChange={handleChange} />
            規約に同意します
          </label>
        </div>
        <button type="submit" disabled={!form.agreed} style={{ marginTop: '8px' }}>
          登録
        </button>
      </form>

      {submitted && (
        <pre style={{ marginTop: '16px', background: '#f4f4f4', padding: '8px' }}>
          {JSON.stringify(submitted, null, 2)}
        </pre>
      )}
    </div>
  );
}

export default SignupForm;

src/App.jsx につなぎます。

src/App.jsx
import SignupForm from './SignupForm';

function App() {
  return <SignupForm />;
}

export default App;

複数種類の入力要素がすべて controlled で動作します。年齢入力は自動で数字のみを受け取り、規約に同意したときだけ送信ボタンが有効になります。送信すると入力結果が JSON で画面に出力されます。

ヒント
実務でより大きなフォームを扱うときは、React Hook Form のようなフォームライブラリを使うことが多いです。検証、エラーメッセージ、パフォーマンス最適化などをあらかじめ実装しており、大きなフォームでコード量を大幅に減らせます。ただし本書の 1 部はライブラリなしで React の基本機能だけを使います。ライブラリも結局は controlled パターンの上で動くものなので、基本を身につけておけばどんなライブラリも素早く身につけられます。

練習問題 #

  1. 上の SignupForm のメール入力に簡単な検証を追加してみてください。form.email@ を含まないなら入力欄の下に赤色の案内(「正しいメール形式ではありません」)を表示し、その間は送信ボタンが無効になるようにします。7 章の条件付きレンダリングのパターンと組み合わせます。
  2. 入力値整形の練習。電話番号入力欄を追加し、数字だけ受け取りつつ、自動で 010-1234-5678 の形式でハイフンが入るように onChange の中で加工してみてください。入力長さに応じて違う正規表現を適用する方式が最も単純です。
  3. 9 章 + 8 章の組み合わせ。新しいフォームコンポーネント TaskForm を作り、送信すると入力したタスクが下のリストに積まれるように書いてみてください。各タスクは crypto.randomUUID() で id を付け、項目の横に「完了」チェックボックスを置いてクリックするとテキストに取り消し線を引きます(textDecoration: 'line-through')。1 部で学んだすべてのパターンが 1 つのコンポーネントに集まる総合練習です。

一行まとめ: フォームの定石は controlled component だ。value / onChange の 1 組で入力を state とまとめる。チェックボックスは checked / e.target.checked、ラジオは name + value + checked={state === 値} のパターン。複数のフィールドはオブジェクト 1 つにまとめて name 属性で区別するパターンがよく見られる。送信は <form onSubmit> + e.preventDefault()。controlled の長所はリアルタイムの加工・検証・連動だ。

次の章 #

本章で 1 部がまとまります。コンポーネント、props、state、イベント、条件付き / リスト / フォームまで — React の核となるビルディングブロック 9 個をすべて手に馴染ませました。ここまで作ったコンポーネントはすべて自分の中で物事が始まって終わっていました。実際のアプリでは外の世界との相互作用が必要です。サーバからデータを取得したり、タイマーを設定したり、ブラウザ API を使ったりといった作業です。

2 部の最初の章である次の第 10 章 useEffectでは、こうした side effect を処理する標準の道具である useEffect フックを学びます。「useEffect をいつ使い、いつ使ってはいけないか」の基準まで一緒に押さえます。

X