React 表单与复杂交互:受控组件、校验与异步提交
简单表单随手写写就能跑,真正麻烦的是「字段一多」「交互一复杂」,状态、校验、提示、提交流程容易搅在一起。
受控组件打底
受控组件是 React 表单的基本单元:值从 props 来,变化通过回调告诉外面。
1type TextFieldProps = {
2 label: string;
3 value: string;
4 onChange: (next: string) => void;
5};
6
7function TextField({ label, value, onChange }: TextFieldProps) {
8 return (
9 <label>
10 <span>{label}</span>
11 <input
12 value={value}
13 onChange={e => onChange(e.target.value)}
14 />
15 </label>
16 );
17}
在表单里组合:
1function ProfileForm() {
2 const [name, setName] = useState('');
3 const [email, setEmail] = useState('');
4
5 function handleSubmit(e: React.FormEvent) {
6 e.preventDefault();
7 // 提交 name / email
8 }
9
10 return (
11 <form onSubmit={handleSubmit}>
12 <TextField label="姓名" value={name} onChange={setName} />
13 <TextField label="邮箱" value={email} onChange={setEmail} />
14 <button type="submit">保存</button>
15 </form>
16 );
17}
先把输入这件事变得可预测,是处理后面各种校验和交互的前提。
组织表单状态:值、错误、touched、dirty
在复杂表单里,单靠几个 useState 很快会乱掉。一般会需要几类信息:
values:当前各字段的值;errors:每个字段当前的校验错误(如果有);touched:用户是否已经操作过该字段;dirty:字段是否被修改过。
可以用一个 Hook 统一管理:
1type Values = {
2 name: string;
3 email: string;
4};
5
6type Errors = Partial<Record<keyof Values, string>>;
7
8function useForm(initialValues: Values) {
9 const [values, setValues] = useState<Values>(initialValues);
10 const [errors, setErrors] = useState<Errors>({});
11 const [touched, setTouched] = useState<Partial<Record<keyof Values, boolean>>>({});
12
13 function setField<K extends keyof Values>(key: K, value: Values[K]) {
14 setValues(prev => ({ ...prev, [key]: value }));
15 }
16
17 function setFieldTouched<K extends keyof Values>(key: K) {
18 setTouched(prev => ({ ...prev, [key]: true }));
19 }
20
21 return {
22 values,
23 errors,
24 touched,
25 setField,
26 setFieldTouched,
27 setErrors,
28 };
29}
表单组件里就可以专注于「在什么时机触发校验」「错误怎么展示」。
校验:时机与粒度
同步校验
常见做法是写一个校验函数:
1function validate(values: Values): Errors {
2 const errors: Errors = {};
3
4 if (!values.name.trim()) {
5 errors.name = '姓名不能为空';
6 }
7 if (!values.email.includes('@')) {
8 errors.email = '邮箱格式不正确';
9 }
10
11 return errors;
12}
常用的触发时机:
onBlur:用户离开字段时校验;onChange:每次输入都校验(对性能要求高一些);onSubmit:提交前整体校验一次。
一种比较折中的方式是:
- 失焦或提交前校验;
- 对有错误的字段,在后续输入时做「轻量实时校验」。
异步校验
例如用户名是否被占用,需要请求后端。
这类校验要防止「旧请求结果覆盖新状态」,常见做法之一是带上一个版本号或使用 abort:
1let currentRequest = 0;
2
3async function validateUsername(username: string) {
4 const requestId = ++currentRequest;
5 const res = await fetch(`/api/check-username?u=${username}`);
6 if (requestId !== currentRequest) return; // 丢弃过期响应
7 const data = await res.json();
8 return data.available;
9}
或者更实际一点,交给 React Query / SWR 一类库,用它们自带的去抖和缓存机制。
异步提交与状态反馈
提交表单时,通常至少要处理:
- 禁用按钮避免重复提交;
- 显示 loading 状态;
- 错误提示和重试;
- 成功后的跳转或提示。
1function ProfileForm() {
2 const { values, errors, setField, setFieldTouched, setErrors } = useForm({
3 name: '',
4 email: '',
5 });
6 const [submitting, setSubmitting] = useState(false);
7 const [submitError, setSubmitError] = useState<string | null>(null);
8
9 async function handleSubmit(e: React.FormEvent) {
10 e.preventDefault();
11 const nextErrors = validate(values);
12 if (Object.keys(nextErrors).length > 0) {
13 setErrors(nextErrors);
14 return;
15 }
16
17 setSubmitting(true);
18 setSubmitError(null);
19 try {
20 await saveProfile(values);
21 // 可以在这里做跳转或全局提示
22 } catch (e) {
23 setSubmitError((e as Error).message);
24 } finally {
25 setSubmitting(false);
26 }
27 }
28
29 // 渲染略
30}
这里的重点是把「校验不过」和「网络/服务器错误」清楚地区分开,用户能看得懂现在卡在了哪一步。
表单库什么时候登场
当表单简单时,自己用几个 Hook 管住就够了;当出现这些信号时,可以考虑引入表单库(如 React Hook Form、Formik 等):
- 表单字段很多,还包含数组/嵌套对象;
- 校验规则复杂,涉及多字段联动;
- 大量业务都要复用同一套「值/错误/touched/dirty」逻辑;
- 对性能有要求,希望减少重渲染。
这些库的内核,基本都围绕前面提到的那几个维度展开,只是帮你把样板代码和性能细节都包了一层。
小结
- 受控组件让表单输入变得可预测,是处理后续复杂交互的基础。
- 把表单状态拆成值、错误、touched、dirty 等几个维度,会比简单地堆
useState好维护得多。 - 校验既要考虑规则本身,也要设计好触发时机和错误展示方式。
- 异步提交阶段要清楚地区分「校验错误」和「网络/服务器错误」,同时给出合适的 loading 和重试体验。