React基礎講座 #9 フォームの扱い (controlled inputs)

読了 9分

前回まで私たちはReactの核心となるビルディングブロックをすべて見てきました。コンポーネント、props、state、イベント、条件付き/リストレンダリングまですべて扱いました。今回からはもう少し実戦的なパターンに入っていきます。最初のテーマはほぼすべてのアプリに登場するフォーム (form) 処理です。

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

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

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

ReactではControlled Componentが定石であり、私たちもそこに集中します。非制御方式は後半でrefを扱うときにまた取り上げる機会があります。

Controlled Componentとは #

すでに#6、#8で使ったパターンです。valueonChangeをペアで結びつけるのが核心です。

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を経由して再び画面に描かれる1サイクルが毎回回っているのです。これが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">DevOps</option>
    </select>
  );
}

<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>
  );
}

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>
  );
}

複数のフィールドを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なので、わざわざ明示しなくてもフォーム内では送信ボタンとして動作しますが、混乱しないように明示する習慣をつけた方がよいです。

入力値の整形 #

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 FormFormikのようなフォームライブラリを使うことが多いです。検証、エラーメッセージ、パフォーマンス最適化などをあらかじめ実装しているので、大きなフォームではコード量を大幅に減らしてくれます。ただし基礎講座であるこのシリーズではライブラリなしでReactの基本機能だけを使います — ライブラリも結局controlledパターンの上で動作するものなので、基本を身につけておけばどんなライブラリも素早く習得できます。

おわりに #

今回の記事ではフォームを扱う定石であるcontrolled componentパターンを見てきました。ポイントは:

  • value/onChangeのペアで入力をstateと結びつける
  • チェックボックスはchecked/e.target.checked、ラジオはname+value+checked={state===値}パターン
  • 複数のフィールドはオブジェクト1つにまとめてname属性で区別するパターンが一般的
  • 送信は<form onSubmit> + e.preventDefault()
  • controlledの強みはリアルタイム加工/検証/連動

これまで私たちが作ってきたコンポーネントはすべて自分の中ですべてのことが始まり終わっていました。しかし実際のアプリでは外部世界とのインタラクションが必要です。サーバーからデータを取ってきたり、タイマーを設定したり、ブラウザAPIを使ったりといった作業ですね。次の記事「React基礎講座 #10 useEffect」では、こうしたside effectを処理する標準ツールであるuseEffectフックを学んでみます。

X