Reactでフォームの入力を制御する書き方としない書き方の違い
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 から作れます。
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 です。
// 制御しない書き方:入力欄の値はブラウザ(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> で包まずに描画しています。
--- 制御する(value と onChange) ---
最初: 描画 1回
「neko」と入力した後: 描画 5回 入力欄=「neko」 4 文字
送信した後: 描画 6回 送信した値:「neko」
--- 制御しない(defaultValue と FormData) ---
最初: 描画 1回
「neko」と入力した後: 描画 1回 入力欄=「neko」
送信した後: 描画 2回 送信した値:「neko」
送信した値はどちらも「neko」でした。制御する書き方は4文字打つ間に4回描画し直され、制御しない書き方は、送信で sent を更新するまで描画が起きませんでした。入力の間の描画の回数は、Chromium で実際にキーを打っても同じでした。

公式リファレンスの、キーを打つたびに起きる描画を減らす方法には、state を持つコンポーネントが大きな範囲を描き直していると、入力のたびに遅くなることがある、と書かれています。対策として、入力欄とその state だけを持つ小さなコンポーネントに分ける方法や、useDeferredValue を使う方法が紹介されています(この記事では試していません)。
打った値を書き換えて state に入れると、カーソルの位置がずれる
制御する入力欄では、onChange で受け取った値に手を加えてから state に入れることもできてしまいます。しかし、公式リファレンスの、カーソルが飛ぶ問題のトラブルシューティングは、state を e.target.value 以外の値にすることをバグとしていて、toUpperCase() で大文字にする例を挙げています。どうなるかを確かめるため、数字以外を取り除く入力欄と、大文字に変える入力欄を作りました。
// 数字以外を取り除いてから 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 に入れる入力欄でも試しています。カーソルの位置は、先頭からの文字数です。
--- そのまま入れる ---
「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 に渡す入力欄で試しました。ボタンで「たま」と「みけ」を切り替えます。
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 で「みけ」を選んだ結果です。
--- 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」と打ってからリセットを押しました。
// 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>
);
}
--- リセットボタン(onReset なし) ---
両方に「neko」と打った後: 制御する入力欄=「neko」 制御しない入力欄=「neko」 state:「neko」
制御する入力欄の value 属性: 「neko」
リセットを押した後: 制御する入力欄=「neko」 制御しない入力欄=「」 state:「neko」
空に戻ったのは、制御しない入力欄だけでした。制御する入力欄は「neko」のままです。この入力欄では、HTML の value 属性も「neko」になっていました。MDN の defaultValue の説明にあるとおり、入力欄の初期値は value 属性の値です。そのため、リセットしても表示が変わらなかったと考えられます。
制御する入力欄も戻したいときは、<form> に onReset を足して state を空にします。ほかの部分は ResetForm と同じです。
<form className="reset-handler" onReset={() => setControlled('')}>
--- リセットボタン(onReset で state を空にする) ---
両方に「neko」と打った後: 制御する入力欄=「neko」 制御しない入力欄=「neko」 state:「neko」
制御する入力欄の value 属性: 「neko」
リセットを押した後: 制御する入力欄=「」 制御しない入力欄=「」 state:「」
制御するつもりで書き間違えたときの警告
value を渡したのに onChange を書き忘れると、打っても値が変わらない入力欄になります。jsdom で「たまこ」と入力しても、表示は「たま」に戻りました。開発用のビルドでは、最初の描画で警告が console.error に出ました。
// value を渡したのに onChange を書き忘れた
export function NoOnChange() {
const [name] = useState('たま');
return <input className="no-onchange" value={name} />;
}
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 ではエラーになりませんでした。
// state の初期値を書かなかったので、最初の value が undefined になる
export function StartsUndefined() {
const [name, setName] = useState<string>();
return <input className="starts-undefined" value={name} onChange={(e) => setName(e.target.value)} />;
}
最初の描画の 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 で読めば足ります。





