Reactのフォームは、各項目の値をstateに保持します。入力欄は value={state} を表示し、onChange でそれを更新します。ユーザーが送信すると、onSubmit ハンドラが e.preventDefault() を呼んでページの再読み込みを止め、すでにstateにある値を使います。
import { useState } from 'react';
export default function App() {
const [name, setName] = useState('');
const [greeting, setGreeting] = useState('');
function handleSubmit(e) {
e.preventDefault();
setGreeting(`Hello, ${name || 'stranger'}!`);
}
return (
<form onSubmit={handleSubmit}>
<label>
Name{' '}
<input name="name" value={name} onChange={(e) => setName(e.target.value)} />
</label>{' '}
<button type="submit">Say hello</button>
<p>{greeting}</p>
</form>
);
}
名前を入力してEnterを押すか、ボタンをクリックしてください。次に e.preventDefault(); の行を削除してみてください。プレビューには、ネイティブの送信でブラウザが送るはずだった項目が表示されます。本物のページなら、ページの再読み込みも起きるところです。
値がstateから来る入力欄を、制御された入力(controlled input)と呼びます。もう1つの方法、つまりDOMに値を持たせて後で読む方法は、制御コンポーネントと非制御コンポーネントで扱っています。
テキスト入力とtextarea
どのテキスト欄も同じように動きます。value はstateから、onChange は e.target.value を読みます。<textarea> も同じパターンです。HTMLではテキストをタグの間に置きますが、Reactでは value として渡します。
import { useState } from 'react';
export default function App() {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
return (
<div style={{ display: 'grid', gap: 8, maxWidth: 320 }}>
<input
value={title}
onChange={(e) => setTitle(e.target.value.toUpperCase())}
placeholder="Title (shouts)"
/>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={4}
maxLength={140}
placeholder="Up to 140 characters"
/>
<small>{140 - body.length} characters left</small>
</div>
);
}
値を自分で設定するので、ユーザーが入力したものを表示前に変えられます。タイトル欄はすべての文字を大文字にします。文字数のカウンターはキーを押すたびに更新されます。body がstateで、変更のたびに再レンダリングされるからです。
select
<select> は、optionの selected ではなく、select要素自身で value を受け取ります。e.target.value は選ばれたoptionの value です。
import { useState } from 'react';
export default function App() {
const [plan, setPlan] = useState('monthly');
const prices = { monthly: '$12 a month', yearly: '$99 a year', free: '$0' };
return (
<div>
<select value={plan} onChange={(e) => setPlan(e.target.value)}>
<option value="free">Free</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
<p>You pay {prices[plan]}.</p>
</div>
);
}
複数選択にするには multiple を付け、value に配列を渡します。選択肢は Array.from(e.target.selectedOptions, (o) => o.value) で読みます。
チェックボックスとラジオボタン
チェックボックスは value ではなく checked で制御し、そのハンドラは true か false である e.target.checked を読みます。グループ内のラジオボタンは name を共有し、stateが自分の値と等しいときにチェックされます。
import { useState } from 'react';
export default function App() {
const [newsletter, setNewsletter] = useState(false);
const [size, setSize] = useState('m');
return (
<div>
<label>
<input
type="checkbox"
checked={newsletter}
onChange={(e) => setNewsletter(e.target.checked)}
/>
Send me the newsletter
</label>
<p>Shirt size:</p>
{['s', 'm', 'l'].map((s) => (
<label key={s} style={{ marginInlineEnd: 12 }}>
<input
type="radio"
name="size"
value={s}
checked={size === s}
onChange={(e) => setSize(e.target.value)}
/>
{s.toUpperCase()}
</label>
))}
<p>
Size {size.toUpperCase()}, newsletter {newsletter ? 'on' : 'off'}.
</p>
</div>
);
}
チェックボックスで e.target.value を読むのはよくある間違いです。value 属性のないチェックボックスでは、チェックが入っていてもいなくても文字列 "on" が返ります。
多くの項目を1つのstateオブジェクトで扱う
10項目あるフォームに、10回の useState 呼び出しと10個のハンドラは必要ありません。1つのオブジェクトを持ち、各入力欄にキーと一致する name を付け、その名前を計算されたキー [e.target.name] として使うハンドラを1つ書きます。
import { useState } from 'react';
export default function App() {
const [form, setForm] = useState({
email: '',
country: 'NO',
terms: false,
});
function handleChange(e) {
const { name, type, value, checked } = e.target;
setForm({ ...form, [name]: type === 'checkbox' ? checked : value });
}
return (
<div style={{ display: 'grid', gap: 8, maxWidth: 280 }}>
<input name="email" value={form.email} onChange={handleChange} placeholder="Email" />
<select name="country" value={form.country} onChange={handleChange}>
<option value="NO">Norway</option>
<option value="JP">Japan</option>
<option value="BR">Brazil</option>
</select>
<label>
<input name="terms" type="checkbox" checked={form.terms} onChange={handleChange} />
I accept the terms
</label>
<pre>{JSON.stringify(form, null, 2)}</pre>
</div>
);
}
ハンドラは入力の種類を確認するので、チェックボックスは真偽値を、それ以外はテキストを保存します。項目を追加するには、最初のオブジェクトにキーを追加し、同じ name を持つ入力欄を追加するだけで、ハンドラは変更不要です。スプレッドがほかのすべての項目を保ちます(配列とオブジェクトの更新を参照)。
フォームを送信する
送信のロジックは、ボタンの onClick ではなく、フォームの onSubmit に置いてください。onSubmit はユーザーがテキスト欄でEnterを押したときにも発生しますが、クリックハンドラではそれを取りこぼします。フォームの中の <button> はデフォルトで送信ボタンなので、ほかのボタンには type="button" を付けて送信しないようにします。
import { useState } from 'react';
export default function App() {
const [form, setForm] = useState({ user: 'ada', password: '' });
function handleChange(e) {
setForm({ ...form, [e.target.name]: e.target.value });
}
function handleSubmit(e) {
e.preventDefault();
console.log('submitting', form);
}
return (
<form onSubmit={handleSubmit}>
<input name="user" value={form.user} onChange={handleChange} />{' '}
<input name="password" type="password" value={form.password} onChange={handleChange} />{' '}
<button type="button" onClick={() => setForm({ user: '', password: '' })}>
Clear
</button>{' '}
<button type="submit">Log in</button>
</form>
);
}
コンソールを開き、パスワードを入力してEnterを押してください。フォームがオブジェクト全体を記録します。「Clear」をクリックしても何も記録されません。type="button" だからです。その属性を削除すると、Clearも送信するようになります。
バリデーションとエラーの表示
単純なバリデーションは、値を受け取ってメッセージのオブジェクトを返す関数です。送信時に実行し、結果をstateに保存して、各メッセージを対応する項目の下に表示します。リクエストの実行中は送信ボタンを無効にし、ダブルクリックでフォームが2回送られないようにします。
import { useState } from 'react';
function validate({ email, age }) {
const errors = {};
if (!email.includes('@')) errors.email = 'Enter a valid email.';
if (!age || Number(age) < 13) errors.age = 'You must be 13 or older.';
return errors;
}
function saveProfile(data) {
return new Promise((resolve) => setTimeout(() => resolve(data), 800));
}
export default function App() {
const [form, setForm] = useState({ email: '', age: '' });
const [errors, setErrors] = useState({});
const [status, setStatus] = useState('idle');
async function handleSubmit(e) {
e.preventDefault();
const found = validate(form);
setErrors(found);
if (Object.keys(found).length > 0) return;
setStatus('saving');
await saveProfile(form);
setStatus('saved');
}
const change = (e) => setForm({ ...form, [e.target.name]: e.target.value });
const error = { color: 'crimson', margin: '4px 0 8px', fontSize: 13 };
return (
<form onSubmit={handleSubmit} noValidate>
<input name="email" value={form.email} onChange={change} placeholder="Email" />
{errors.email && <p style={error}>{errors.email}</p>}
<input name="age" type="number" value={form.age} onChange={change} placeholder="Age" />
{errors.age && <p style={error}>{errors.age}</p>}
<div style={{ marginTop: 8 }}>
<button disabled={status === 'saving'}>
{status === 'saving' ? 'Saving...' : 'Save'}
</button>
{status === 'saved' && ' Saved.'}
</div>
</form>
);
}
項目が空のままSaveをクリックすると両方のメッセージが表示されるので、それから入力してください。saveProfile は本物のリクエストの代わりで、800ミリ秒待ってから解決します。noValidate 属性はブラウザ独自のバリデーションの吹き出しを無効にし、自分のメッセージだけが表示されるようにします。これを外して入力欄に required を付け、比べてみてください。
エラーは保存せずにレンダリング中に計算することもできます。たとえば const emailError = form.email && !form.email.includes('@') とすれば、ユーザーの入力中にメッセージが表示されます。フォームに合うタイミングを選んでください。送信時のほうが落ち着いていて、入力中のほうがフィードバックが速くなります。
React 19のフォームアクション
React 19では、フォームを扱う方法がもう1つ追加されました。フォームの action propに関数を渡します。Reactがページの再読み込みを防ぎ、name を持つすべての項目を含む FormData オブジェクトを渡してその関数を呼びます。項目にstateはまったく必要ありません。非制御の入力欄で、アクションが終わるとReactがクリアします。
import { useState } from 'react';
function addComment(text) {
return new Promise((resolve) => setTimeout(() => resolve(text), 500));
}
export default function App() {
const [comments, setComments] = useState([]);
async function postComment(formData) {
const text = formData.get('comment');
console.log('action received', text);
const saved = await addComment(text);
setComments((list) => [...list, saved]);
}
return (
<form action={postComment}>
<input name="comment" defaultValue="Nice docs" />{' '}
<button type="submit">Post</button>
<ul>
{comments.map((c, i) => (
<li key={i}>{c}</li>
))}
</ul>
</form>
);
}
自分のコメントを入力して投稿してください。リストには入力したテキストが表示され、入力欄は Nice docs に戻ります。アクションが解決した後、Reactが非制御の入力欄を defaultValue にリセットするからです。アクションは非同期にでき、実行中、Reactはその更新をトランジションとして扱います。
アクションの保留中の状態やエラーを表示するには、useActionStateで包みます。これは最新の結果、フォーム用に包んだアクション、isPending フラグを返します。フォームの奥深くにあるボタンも、react-dom の useFormStatus で保留中の状態を読めます。
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Posting...' : 'Post'}</button>;
}
ここのエディタでは useFormStatus が読み込まれていないので、この静的なブロックでだけ示しています。これはフォームを描画するコンポーネントではなく、<form> の中で描画されるコンポーネントから呼ぶ必要があります。
onSubmit を使う制御されたstateも、FormData を使うフォームアクションも、React 19ではどちらも使えます。制御された項目は、入力中に反応するフォーム(リアルタイムのバリデーション、文字数カウンター、互いに依存する項目)に向いています。アクションは、ユーザーが送信したときだけ値が必要なフォームに向いています。