
一句话:React 里处理表单有两条路——受控表单(state 驱动,适合实时校验与联动)与非受控 /
FormData(提交时收集,代码更少);React 19 的form action进一步把「提交 + loading + 错误」统一成一套模式。
一、单字段受控表单
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); // ⭐ 阻止浏览器默认的表单提交(会刷新页面)
console.log({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<button type="submit">登录</button>
</form>
);
}
二、多字段状态管理
// ① 多个独立的 useState(字段少时最清晰)
const [name, setName] = useState('');
const [age, setAge] = useState(0);
const [agree, setAgree] = useState(false);
// ② 一个对象(字段多时更简洁,但更新要展开)
const [form, setForm] = useState({ name: '', age: 0, agree: false });
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setForm((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value, // ⭐ 区分 checkbox
}));
};
// 使用:name 必须与 state 的键一致
<input name="name" value={form.name} onChange={handleChange} />
<input name="age" type="number" value={form.age} onChange={handleChange} />
<input name="agree" type="checkbox" checked={form.agree} onChange={handleChange} />
// ③ 用 useReducer(字段多、逻辑复杂时推荐)
type FormState = { name: string; age: number; agree: boolean };
type FormAction =
| { type: 'change'; field: keyof FormState; value: string | number | boolean }
| { type: 'reset' };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'change':
return { ...state, [action.field]: action.value };
case 'reset':
return { name: '', age: 0, agree: false };
default:
return state;
}
}
const [form, dispatch] = useReducer(formReducer, { name: '', age: 0, agree: false });
<input
name="name"
value={form.name}
onChange={(e) => dispatch({ type: 'change', field: 'name', value: e.target.value })}
/>;
| 方案 | 适用 | 更新复杂度 |
|---|---|---|
多个 useState |
2~3 个字段 | 简单 |
| 单个对象 state | 4~10 个字段 | 需要展开 |
useReducer |
字段多 / 有重置与复杂逻辑 | 集中管理 |
非受控 + FormData |
只需提交时的值 | 最简单 |
三、通用 change 处理
// 一个通用的「按 name 更新」处理函数(覆盖 input / select / textarea / checkbox)
function useFormState<T extends Record<string, unknown>>(initial: T) {
const [values, setValues] = useState(initial);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const target = e.target;
const { name, type } = target;
let value: unknown = target.value;
if (type === 'checkbox') value = (target as HTMLInputElement).checked;
else if (type === 'number' || type === 'range') value = Number(target.value);
else if ((target as HTMLSelectElement).multiple) {
value = Array.from((target as unknown as HTMLSelectElement).selectedOptions, (o) => o.value);
}
setValues((prev) => ({ ...prev, [name]: value }));
},
[]
);
const reset = useCallback(() => setValues(initial), [initial]);
return { values, handleChange, reset, setValues };
}
四、表单校验
// ① 实时校验(受控表单的优势)
function Form() {
const [email, setEmail] = useState('');
const [touched, setTouched] = useState(false);
const error = useMemo(() => {
if (!email) return '请输入邮箱';
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return '邮箱格式不正确';
return '';
}, [email]);
return (
<div>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setTouched(true)} // ⭐ 失焦后才显示错误(体验更好)
aria-invalid={!!error && touched}
aria-describedby="email-error"
/>
{touched && error && <p id="email-error" role="alert">{error}</p>}
</div>
);
}
// ② 提交时校验
const handleSubmit = (e) => {
e.preventDefault();
const errors = validate(form);
if (Object.keys(errors).length) {
setErrors(errors);
return;
}
submit(form);
};
// ③ 用第三方校验库(zod / yup)
import { z } from 'zod';
const schema = z.object({
email: z.string().email('邮箱格式不正确'),
age: z.number().min(18, '年龄不能小于 18'),
});
const result = schema.safeParse(form);
if (!result.success) {
setErrors(result.error.flatten().fieldErrors);
}
校验的两个原则:
① 前端校验只是为了【体验】(即时反馈),服务端必须重新校验
② 错误提示要「及时但不打扰」—— 常见做法是「失焦时首次校验,之后实时校验」
五、非受控 + FormData
function SimpleForm({ onSubmit }: { onSubmit: (data: Record<string, string>) => void }) {
const formRef = useRef<HTMLFormElement>(null);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget); // ⭐ 直接从 form 元素收集
onSubmit(Object.fromEntries(fd) as Record<string, string>);
};
return (
<form ref={formRef} onSubmit={handleSubmit}>
<input name="username" defaultValue="张三" /> {/* ⭐ 必须有 name */}
<input name="age" type="number" defaultValue={18} />
<select name="city" defaultValue="bj">
<option value="bj">北京</option>
<option value="sh">上海</option>
</select>
<input type="file" name="avatar" /> {/* file 只能用这种方式 */}
<button type="submit">提交</button>
</form>
);
}
| 维度 | 受控 + useState |
非受控 + FormData |
|---|---|---|
| 代码量 | 多 | 少 |
| 实时校验/联动 | ✅ | ❌(需监听原生事件) |
| 重渲染 | 每次输入都渲染 | 不渲染 |
| 文件上传 | ❌ | ✅ |
| 适合 | 字段少、需实时反馈 | 字段多、只取提交值 |
六、React 19 的 Actions
// React 19:用 action 属性把「提交 + loading + 错误」统一起来
import { useActionState } from 'react';
function SignupForm() {
const [state, formAction, isPending] = useActionState(
async (prevState, formData: FormData) => {
// 这个函数可以是 async,也可以在服务端运行(Server Action)
const res = await fetch('/api/signup', { method: 'POST', body: formData });
if (!res.ok) return { error: '注册失败,请重试' };
return { success: true };
},
{ error: '', success: false }
);
return (
<form action={formAction}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button disabled={isPending}>{isPending ? '提交中…' : '注册'}</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
);
}
// 配套的 Hooks(React 19)
import { useFormStatus, useOptimistic } from 'react';
// useFormStatus:读取最近的 form 提交状态(必须在 form 的子组件里用)
function SubmitButton() {
const { pending, data, method } = useFormStatus();
return <button disabled={pending}>{pending ? '提交中…' : '提交'}</button>;
}
// useOptimistic:乐观更新
function Comments({ comments, addComment }) {
const [optimistic, addOptimistic] = useOptimistic(
comments,
(current, newComment: string) => [...current, { id: 'temp', text: newComment, pending: true }]
);
async function submit(formData: FormData) {
addOptimistic(formData.get('text') as string); // 立即显示
await addComment(formData.get('text') as string); // 后台真正提交
}
return (
<form action={submit}>
{optimistic.map((c) => <Comment key={c.id} {...c} />)}
<input name="text" />
<button>发送</button>
</form>
);
}
| 概念 | 作用 |
|---|---|
<form action={fn}> |
提交时自动调用 fn(formData),并自动处理 preventDefault 与 reset |
useActionState |
管理 action 的返回值状态与 pending 状态 |
useFormStatus |
在子组件里读取所属 form 的提交状态 |
useOptimistic |
乐观更新(先展示结果,失败再回滚) |
| Server Actions | "use server" 标记的函数,表单可直接调用服务端逻辑 |
七、第三方方案
// react-hook-form:内部用【非受控 + ref】,性能好、校验内置
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
function Form() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(schema),
defaultValues: { username: '', age: 18 },
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register('username')} />
{errors.username && <span>{errors.username.message}</span>}
<input type="number" {...register('age', { valueAsNumber: true })} />
<button disabled={isSubmitting}>提交</button>
</form>
);
}
| 方案 | 特点 | 适合 |
|---|---|---|
受控 + useState |
直观、可控 | 小表单 |
非受控 + FormData |
代码最少 | 简单提交 |
| react-hook-form | 非受控 + 订阅,性能好、生态好 | 中型/大型表单 |
| Formik | 受控方案,较重 | 老项目 |
| React 19 Actions | 原生集成、与 Server Actions 配合 | 新项目、Next.js |
八、常见坑
// ① 忘记 preventDefault → 页面刷新
<form onSubmit={handleSubmit}> // ⚠️ 需要在处理函数里 e.preventDefault()
// ② 受控 input 忘了 onChange
<input value={v} /> // ⚠️ 只读 + 警告
// ③ 忘记 name 导致 FormData 收集不到
<input defaultValue="a" /> // ❌ FormData 里没有这个字段
// ④ checkbox 用 value 而不是 checked
<input type="checkbox" value={agree} onChange={...} /> // ❌
// ⑤ select 的默认值写在 option 上
<option selected>a</option> // ❌ React 用 select 的 value
// ⑥ 数字字段拿到的是字符串
const age = formData.get('age'); // string 类型
// ✅ Number(age) 或 register('age', { valueAsNumber: true })
// ⑦ 在 onChange 里做昂贵计算 → 输入卡顿
onChange={(e) => { setV(e.target.value); search(e.target.value); }} // ⚠️ 每次按键都请求
// ✅ 防抖 / useDeferredValue
// ⑧ 提交时没有禁用按钮 → 重复提交
<button>提交</button> // ⚠️ 用户连点会发多次请求
<button disabled={isSubmitting}>提交</button> // ✅
// ⑨ 错误提示没有关联到输入框(无障碍问题)
<span>{error}</span> // ⚠️ 读屏用户不知道是哪个字段
<input aria-invalid={!!error} aria-describedby="err-1" />
<p id="err-1" role="alert">{error}</p> // ✅
// ⑩ 表单重置时受控字段没重置
const handleReset = () => { setForm(initial); } // ✅ 需要手动重置所有 state
// 或使用 <form> 的 reset + key 强制重建(不推荐,会丢焦点)
面试延伸
- 「React 里的表单怎么做?」
两条路:① 受控表单——每个字段的值存在 state 里(value+onChange),React 是唯一数据源,能做实时校验、格式化、联动,代价是每次输入都重渲染;② 非受控——用defaultValue设初值,提交时通过ref或new FormData(e.currentTarget)收集,代码更少、不重渲染,但无法实时响应。字段少用受控、字段多用非受控或用 react-hook-form。 -
「为什么受控组件必须写
onChange?」
因为 React 认为你「想让 React 控制这个值」(提供了value属性),但没提供「值怎么变」的路径——于是用户的输入会被value立即覆盖回去,输入框变成只读,React 会发出警告(You provided a value prop to a form field without an onChange handler)。如果确实要只读,要显式写readOnly。 -
「多字段表单怎么管理状态?」
按字段数量选择:2~3 个用多个useState(最清晰);4~10 个用一个对象 state + 统一的handleChange(按e.target.name更新);字段很多或逻辑复杂用useReducer(把「状态 + 变更规则」集中管理,还方便做重置);超大表单直接用 react-hook-form(非受控 + 订阅,避免大量重渲染)。 -
「
checkbox和select的受控写法有什么特别之处?」
两处差异:①checkbox用checked而不是value,事件里读e.target.checked(布尔值),而value在复选框上表示「提交时的值」;②select的value要写在<select>上(而不是给某个<option>加selected),多选时传数组。此外radio用checked并配合相同的name分组。 -
「表单校验应该怎么做?」
分三层:① 前端即时校验(受控表单的优势)——但要注意「失焦时才首次显示错误」体验更好(否则用户刚输入一半就报红);② 提交时统一校验(无论受控还是非受控都能做);③ 服务端校验(必须)——前端校验只是体验优化,绕过它太容易。复杂规则建议用 zod / yup 声明式定义 schema,同时获得运行时的校验与 TS 类型推导。 -
「怎么防止表单重复提交?」
① 禁用提交按钮(disabled={isSubmitting})——最常用;② 提交中显示 loading;③ 在提交函数里加锁(用ref记录是否正在提交,避免异步竞态);④ 服务端做幂等(用幂等键 / 唯一约束),因为即使前端防住了,用户也可能刷新重试或直接调接口。React 19 的useFormStatus能方便地拿到pending状态。 -
「React 19 的 Actions 解决了什么问题?」
把表单提交的三条重复逻辑统一了:① 手动preventDefault+ 收集数据(现在写成<form action={fn}>,React 会把FormData传进去并自动处理这两步);② 手动管理loading(现在用useActionState返回的isPending或子组件里的useFormStatus);③ 手动管理错误与结果状态(现在useActionState统一管)。配合 Server Actions 还能直接在表单里调用服务端函数,少写一层接口。 -
「受控表单的性能问题怎么解决?」
三个方向:① 拆分子组件——每个字段自己管理 state,输入只重渲染该字段而不是整张表单;② 改用非受控(ref/FormData)——完全不触发重渲染;③ 延迟昂贵计算——如果输入要驱动搜索或过滤,用防抖或useDeferredValue把重计算降优先级。react-hook-form 之所以快,就是因为它用「非受控 + 订阅」避免了每次输入都重渲染整张表单。
一句话速记
表单两条路:受控(
value+onChange,能实时校验/联动,但每次输入都重渲染)与非受控(defaultValue+ref/FormData,代码少不重渲染);多字段用「单对象 state + 按name统一处理」或useReducer,大表单用 react-hook-form;checkbox用checked、select的value写在 select 上、file只能非受控;提交记得preventDefault+ 禁用按钮防重复提交;React 19 用<form action>+useActionState+useFormStatus统一处理提交状态。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!