React 表单把每个字段的值保存在状态中:输入框显示 value={state},并通过 onChange 更新它。用户提交时,onSubmit 处理函数调用 e.preventDefault() 阻止页面刷新,然后使用已经保存在状态中的值。
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(); 这一行:预览会显示浏览器在原生提交时会发送的字段,而在真实页面中,原生提交还会刷新页面。
值来自状态的输入框称为受控输入框。另一种做法是让 DOM 保存值、之后再读取,这在受控组件与非受控组件中有讲解。
文本输入框与 textarea
每个文本字段的工作方式都一样:value 来自状态,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 是状态,每次改动都会重新渲染。
Select
<select> 的 value 写在 select 元素本身上,而不是在某个 option 上写 selected。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) 读取选中的项。
复选框与单选按钮
复选框用 checked 而不是 value 来控制,它的处理函数读取 e.target.checked,值为 true 或 false。同一组的单选按钮共用一个 name;当状态等于某个按钮的值时,它就被选中。
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"。
用一个状态对象管理多个字段
一个有十个字段的表单,不需要十个 useState 调用和十个处理函数。保存一个对象,给每个输入框一个与键名一致的 name,再写一个处理函数,把 name 作为计算属性名使用:[e.target.name]。
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 相同的输入框;处理函数无需修改。展开语法会保留其他所有字段(参见更新数组和对象)。
提交表单
把提交逻辑放在表单的 onSubmit 上,而不是按钮的 onClick 上。用户在文本字段中按 Enter 时,onSubmit 也会触发,而点击处理函数会漏掉这种情况。表单中的 <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 也会开始提交表单。
校验与显示错误
简单的校验就是一个函数:接收这些值,返回一个由提示信息组成的对象。在提交时运行它,把结果存进状态,并在每个字段下方显示对应的信息。请求进行期间禁用提交按钮,这样双击就不会把表单发送两次。
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 毫秒后 resolve。noValidate 属性关闭了浏览器自带的校验气泡,只显示你自己的提示信息;去掉它,再给某个输入框加上 required,就能对比效果。
错误也可以在渲染时计算,而不是保存起来,比如 const emailError = form.email && !form.email.includes('@'),这样会在用户输入时就显示提示。根据表单选择合适的时机:提交时校验更平和,输入时校验反馈更快。
React 19 中的表单 action
React 19 提供了另一种处理表单的方式。给表单的 action prop 传入一个函数。React 会替你阻止页面刷新,并用一个 FormData 对象调用这个函数,其中包含每个带有 name 的字段。这些字段根本不需要状态:它们是非受控的,action 完成后 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:action 完成后,React 会把非受控字段重置为它们的 defaultValue。action 可以是异步的,在它运行期间,React 会把这次更新当作一个过渡(transition)。
要显示 action 的等待状态或错误,可以用 useActionState 包装它,它会返回最新的结果、一个供表单使用的包装后的 action,以及一个 isPending 标志。表单深处的按钮也可以用 react-dom 中的 useFormStatus 读取等待状态:
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Posting...' : 'Post'}</button>;
}
这里的编辑器没有加载 useFormStatus,所以它只出现在这个静态代码块中。它必须在渲染于 <form> 内部的组件中调用,而不是在渲染表单的那个组件中调用。
在 React 19 中,受控状态加 onSubmit,和表单 action 加 FormData,两种方式都可以用。受控字段适合在输入时就要做出反应的表单(实时校验、字符计数器、相互依赖的字段)。action 适合只在用户提交时才需要这些值的表单。