
一句话:React 里处理表单有两条路——受控表单(state 驱动,适合实时校验与联动)与非受控 /
FormData(提交时收集,代码更少);React 19 的form action进一步把「提交 + loading + 错误」统一成一套模式。
一、单字段受控表单
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); // ⭐ 阻止浏览器默认的表单提交(会刷新页面)
console.log({ email, password });
};
return (
);
}
二、多字段状态管理
// ① 多个独立的 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) => {
const { name, value, type, checked } = e.target;
setForm((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value, // ⭐ 区分 checkbox
}));
};
// 使用:name 必须与 state 的键一致
// ③ 用 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 });
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>(initial: T) {
const [values, setValues] = useState(initial);
const handleChange = useCallback(
(e: React.ChangeEvent) => {
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 (
setEmail(e.target.value)}
onBlur={() => setTouched(true)} // ⭐ 失焦后才显示错误(体验更好)
aria-invalid={!!error && touched}
aria-describedby="email-error"
/>
{touched && error && {error}
}
);
}
// ② 提交时校验
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) => void }) {
const formRef = useRef(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const fd = new FormData(e.currentTarget); // ⭐ 直接从 form 元素收集
onSubmit(Object.fromEntries(fd) as Record);
};
return (
);
}
| 维度 | 受控 + 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 (
);
}
// 配套的 Hooks(React 19)
import { useFormStatus, useOptimistic } from 'react';
// useFormStatus:读取最近的 form 提交状态(必须在 form 的子组件里用)
function SubmitButton() {
const { pending, data, method } = useFormStatus();
return ;
}
// 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={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 (
);
}
| 方案 | 特点 | 适合 |
|---|---|---|
受控 + useState |
直观、可控 | 小表单 |
非受控 + FormData |
代码最少 | 简单提交 |
| react-hook-form | 非受控 + 订阅,性能好、生态好 | 中型/大型表单 |
| Formik | 受控方案,较重 | 老项目 |
| React 19 Actions | 原生集成、与 Server Actions 配合 | 新项目、Next.js |
八、常见坑
// ① 忘记 preventDefault → 页面刷新
面试延伸
- 「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。学习了。。。。
网站不错!!!!
简洁实用,好文章!