Reactでフォームの入力を制御する書き方としない書き方の違い

​​React​TypeScript,useState,フォーム

React の入力欄には、value と onChange で値を state に持たせる「制御する」書き方と、defaultValue で初期値だけを渡し、値はブラウザに任せる「制御しない」書き方の2通りがあります。検証では、制御すると1文字打つたびに描画し直され、制御しないと送信するまで描画し直されませんでした。リセットボタンの効き方や、初期値を後から変えたときの動きも違いました。

筆者は検証環境で、同じ入力欄を2つの書き方で作り、同じ操作をして比べました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、jsdom 29.1.1 です。.tsx を tsc で型チェックしてから esbuild で変換し(開発用のビルド)、jsdom と Chromium 141(Playwright 1.56.1 で操作)で動かしました。GIF への変換には ffmpeg 6.1.1 を使っています。

2つの書き方で、名前の入力欄を作る

制御する書き方では、value に state を渡し、onChange で state を更新します。公式リファレンスの、state で入力欄を制御する方法の説明のとおり、入力欄にはいつも value に渡した値が表示されます。値が state にあるので、文字数の表示も state から作れます。

tsx
import { useState, type FormEvent } from 'react';
import { renders } from './renders';

// 制御する書き方:入力欄の値は state が持つ
export function ControlledName() {
  renders.controlled++;
  const [name, setName] = useState('');
  const [sent, setSent] = useState<string | null>(null);
  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setSent(name);
  }
  return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={name} onChange={(e) => setName(e.target.value)} />
      <p className="info">{name.length} 文字</p>
      <button type="submit">送信</button>
      {sent !== null && <p className="sent">送信した値:「{sent}」</p>}
    </form>
  );
}

制御しない書き方では、defaultValue に初期値だけを渡します(公式リファレンスの、入力欄に初期値を渡す方法の説明)。state で入力欄を制御する方法の説明の冒頭にあるとおり、defaultValue は初期値を決めるだけで、今の値は決めません。値は、送信のときに FormData で読み出します(公式リファレンスの、送信のときに入力の値を読む方法)。FormData のキーは、入力欄の name です。

tsx
// 制御しない書き方:入力欄の値はブラウザ(DOM)が持ち、送信のときに読む
export function UncontrolledName() {
  renders.uncontrolled++;
  const [sent, setSent] = useState<string | null>(null);
  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    setSent(String(formData.get('name')));
  }
  return (
    <form onSubmit={handleSubmit}>
      <input name="name" defaultValue="" />
      <button type="submit">送信</button>
      {sent !== null && <p className="sent">送信した値:「{sent}」</p>}
    </form>
  );
}

renders は、コンポーネントの関数が呼ばれた回数を数えるためのオブジェクトです。jsdom で「neko」と1文字ずつ入力してから送信し、描画の回数を数えました。検証では <StrictMode> で包まずに描画しています。

text
--- 制御する(value と onChange) ---
最初: 描画 1回
「neko」と入力した後: 描画 5回 入力欄=「neko」 4 文字
送信した後: 描画 6回 送信した値:「neko」
--- 制御しない(defaultValue と FormData) ---
最初: 描画 1回
「neko」と入力した後: 描画 1回 入力欄=「neko」 
送信した後: 描画 2回 送信した値:「neko」

送信した値はどちらも「neko」でした。制御する書き方は4文字打つ間に4回描画し直され、制御しない書き方は、送信で sent を更新するまで描画が起きませんでした。入力の間の描画の回数は、Chromium で実際にキーを打っても同じでした。

上の制御する入力欄では、キーを押すたびに描画の回数が2、3、4、5と増え、文字数も変わる。下の制御しない入力欄では、4文字打っても描画の回数は1のまま
2つの入力欄に「neko」と1文字ずつ打ったときの、描画の回数の変わり方(上が制御する書き方、下が制御しない書き方)

公式リファレンスの、キーを打つたびに起きる描画を減らす方法には、state を持つコンポーネントが大きな範囲を描き直していると、入力のたびに遅くなることがある、と書かれています。対策として、入力欄とその state だけを持つ小さなコンポーネントに分ける方法や、useDeferredValue を使う方法が紹介されています(この記事では試していません)。

打った値を書き換えて state に入れると、カーソルの位置がずれる

制御する入力欄では、onChange で受け取った値に手を加えてから state に入れることもできてしまいます。しかし、公式リファレンスの、カーソルが飛ぶ問題のトラブルシューティングは、state を e.target.value 以外の値にすることをバグとしていて、toUpperCase() で大文字にする例を挙げています。どうなるかを確かめるため、数字以外を取り除く入力欄と、大文字に変える入力欄を作りました。

tsx
// 数字以外を取り除いてから state に入れる
export function DigitsOnly() {
  const [zip, setZip] = useState('');
  return <input className="digits" value={zip} onChange={(e) => setZip(e.target.value.replace(/\D/g, ''))} />;
}

// 大文字に変えてから state に入れる
export function UpperCase() {
  const [code, setCode] = useState('');
  return <input className="upper" value={code} onChange={(e) => setCode(e.target.value.toUpperCase())} />;
}

Chromium で4文字打ってから、カーソルを2文字目の後ろに置き、もう1文字打ちました。比べるために、受け取った値をそのまま state に入れる入力欄でも試しています。カーソルの位置は、先頭からの文字数です。

text
--- そのまま入れる ---
「abcd」と打った後: 値=「abcd」 カーソル=4
カーソルを2の位置に置いて「x」を打った後: 値=「abxcd」 カーソル=3
--- 数字以外を取り除く(数字を打つ) ---
「1234」と打った後: 値=「1234」 カーソル=4
カーソルを2の位置に置いて「5」を打った後: 値=「12534」 カーソル=3
--- 数字以外を取り除く(数字以外を打つ) ---
「1234」と打った後: 値=「1234」 カーソル=4
カーソルを2の位置に置いて「a」を打った後: 値=「1234」 カーソル=4
--- 大文字に変える ---
「abcd」と打った後: 値=「ABCD」 カーソル=4
カーソルを2の位置に置いて「x」を打った後: 値=「ABXCD」 カーソル=5

数字以外の「a」は入力欄に残らず、小文字の「x」は大文字になりました。ただし、打った値と違う値を state に入れた場合は、Chromium ではカーソルが末尾に移りました。「ABXCD」では、カーソルは「X」の後ろ(3の位置)ではなく、末尾の5の位置にありました。打った値をそのまま入れた場合は、カーソルは打った文字の後ろに残りました。

リンク先の見出しは「カーソルが先頭に飛ぶ」で、検証で見た「末尾に移る」とは向きが違います。ただ、原因の1つとして、state を e.target.value 以外の値にすることが挙げられています(ほかに、非同期で更新する場合なども載っています)。直し方は、onChange の中で e.target.value を同期的にそのまま state に入れることです。

制御しない入力欄は、初期値を後から変えても表示が変わらない

選んだ猫の名前を defaultValue に渡す入力欄で試しました。ボタンで「たま」と「みけ」を切り替えます。

tsx
type Cat = { id: number; name: string };
const cats: Cat[] = [
  { id: 1, name: 'たま' },
  { id: 2, name: 'みけ' },
];

// defaultValue に、選んだ猫の名前を渡す
function NameField({ cat }: { cat: Cat }) {
  return <input className="cat-name" name="name" defaultValue={cat.name} />;
}

export function CatEditor({ useKey }: { useKey: boolean }) {
  const [cat, setCat] = useState(cats[0]);
  return (
    <div>
      {cats.map((c) => (
        <button key={c.id} type="button" onClick={() => setCat(c)}>{c.name}</button>
      ))}
      {useKey ? <NameField key={cat.id} cat={cat} /> : <NameField cat={cat} />}
    </div>
  );
}

useKey が false のときは、key を渡さずに描きます。jsdom で「みけ」を選んだ結果です。

text
--- key なし ---
最初: 入力欄=「たま」
「みけ」を選んだ後: 入力欄=「たま」 defaultValue=「みけ」 同じ input 要素か: true
--- key={cat.id} あり ---
最初: 入力欄=「たま」
「みけ」を選んだ後: 入力欄=「みけ」 defaultValue=「みけ」 同じ input 要素か: false

key がないと、入力欄の defaultValue は「みけ」に変わったのに、表示は「たま」のままでした。Chromium では、何も打っていない状態で「みけ」を選んでも、表示は「たま」のままでした。

key={cat.id} を渡すと、input 要素が作り直され、「みけ」が表示されました。Chromium では、「たま」に「ちゃん」と打ってから「みけ」を選ぶと、打った「たまちゃん」は消えて「みけ」になりました。公式ドキュメントの、key でフォームを作り直す方法と同じ考え方です。

onReset がないと、リセットボタンで戻るのは制御しない入力欄だけ

type="reset" のボタンは、MDN で説明されている HTMLFormElement.reset() と同じく、フォームの入力欄を初期値に戻します。Chromium で、制御する入力欄と制御しない入力欄を1つのフォームに並べ、両方に「neko」と打ってからリセットを押しました。

tsx
// type="reset" のボタンで、制御する入力欄と制御しない入力欄を戻す
export function ResetForm() {
  const [controlled, setControlled] = useState('');
  return (
    <form className="reset">
      <input className="r-controlled" value={controlled} onChange={(e) => setControlled(e.target.value)} />
      <input className="r-uncontrolled" defaultValue="" />
      <p className="r-state">state:「{controlled}」</p>
      <button type="reset">リセット</button>
    </form>
  );
}
text
--- リセットボタン(onReset なし) ---
両方に「neko」と打った後: 制御する入力欄=「neko」 制御しない入力欄=「neko」 state:「neko」
  制御する入力欄の value 属性: 「neko」
リセットを押した後: 制御する入力欄=「neko」 制御しない入力欄=「」 state:「neko」

空に戻ったのは、制御しない入力欄だけでした。制御する入力欄は「neko」のままです。この入力欄では、HTML の value 属性も「neko」になっていました。MDN の defaultValue の説明にあるとおり、入力欄の初期値は value 属性の値です。そのため、リセットしても表示が変わらなかったと考えられます。

制御する入力欄も戻したいときは、<form> に onReset を足して state を空にします。ほかの部分は ResetForm と同じです。

tsx
<form className="reset-handler" onReset={() => setControlled('')}>
text
--- リセットボタン(onReset で state を空にする) ---
両方に「neko」と打った後: 制御する入力欄=「neko」 制御しない入力欄=「neko」 state:「neko」
  制御する入力欄の value 属性: 「neko」
リセットを押した後: 制御する入力欄=「」 制御しない入力欄=「」 state:「」

制御するつもりで書き間違えたときの警告

value を渡したのに onChange を書き忘れると、打っても値が変わらない入力欄になります。jsdom で「たまこ」と入力しても、表示は「たま」に戻りました。開発用のビルドでは、最初の描画で警告が console.error に出ました。

tsx
// value を渡したのに onChange を書き忘れた
export function NoOnChange() {
  const [name] = useState('たま');
  return <input className="no-onchange" value={name} />;
}
text
console.error: You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`.
「たまこ」と入力した後: 入力欄=「たま」

state の初期値を書き忘れた場合は、最初の描画では警告が出ず、1文字打って state に文字列が入った時点で、「制御しない入力欄が制御する入力欄に変わった」という警告が出ました。この書き方は tsc ではエラーになりませんでした。

tsx
// state の初期値を書かなかったので、最初の value が undefined になる
export function StartsUndefined() {
  const [name, setName] = useState<string>();
  return <input className="starts-undefined" value={name} onChange={(e) => setName(e.target.value)} />;
}
text
最初の描画の console.error: なし
「み」と入力した後: 入力欄=「み」
console.error: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://react.dev/link/controlled-components

公式リファレンスの、この警告についてのトラブルシューティングによると、初期値を空の文字列 '' にするか、value={name ?? ''} のように渡せば、いつも文字列になります(この直し方は試していません)。

どちらを選ぶか

検証の結果を表にまとめます。制御しない書き方で打った値を書き換える方法は、試していないので「―」にしています。

比べたこと 制御する(value と onChange) 制御しない(defaultValue)
値のありか state ブラウザの入力欄
この記事での値の読み方 いつでも state から 送信のときに FormData で
1文字打ったとき 描画し直される 描画し直されない
打った値を書き換えて state に入れる Chromium ではカーソルが末尾に移った(公式はバグとしている) ―
初期値を後から変える value に渡す state を変えれば表示も変わる key で作り直さないと変わらない
リセットボタン onReset で state を戻す必要がある 初期値に戻る
ポイント

入力のたびに画面のほかの部分を変えたいときは、制御する書き方にします。onChange では e.target.value をそのまま state に入れます。送信するときに値がそろえばよいフォームなら、制御しない書き方で name を付け、FormData で読めば足ります。

​Posted by 86