表单不是一组字符串输入框,而是一段允许暂时无效、可被触碰、可被校验、可能提交失败并需要恢复的编辑会话。正确设计的关键不是“所有字段都放进 State”,而是明确当前值、初始基线、交互元数据和服务器事实分别由谁拥有。
一、为什么表单状态容易失控
一个“编辑订单”表单通常同时包含:
- 用户正在输入的 Draft;
- 服务端加载的 Initial Values;
- Dirty、Touched 和字段错误;
- 同步与异步校验状态;
- 跨字段约束;
- Submit Pending、成功和失败;
- 服务端返回的字段错误;
- Reset、切换实体与离页确认。
如果只维护一个 values 对象,很快会出现:
defaultValue更新了,但输入框仍显示旧内容;- 输入从 Uncontrolled 切成 Controlled,React 发出警告;
- 用户刚输入一个字符就看到整页错误;
- 异步校验旧响应覆盖新输入;
- 提交成功后字段重置了,Dirty 却仍为
true; - 服务端 Query Refetch 覆盖尚未提交的 Draft;
- 两次点击产生重复 Mutation;
- Reset 只清了字段值,没有清 Error 和 Touched。
本文以现代 React、TypeScript 和浏览器原生 Form API 为主,不绑定具体表单库。React 19 的 Form Action、useActionState 和 useFormStatus 会在 Submit State 中说明,但框架 Server Action、渐进增强和序列化行为应以项目锁定版本为准。
核心结论
- Form State 是未提交 Draft,不等于 Server State 中已保存实体。
- Controlled Input 的当前值由 React State 决定,必须在
onChange中同步更新。 - Uncontrolled Input 的当前值由 DOM 保存,通常在提交时通过
FormData读取。 - 同一输入生命周期内不应在 Controlled 与 Uncontrolled 之间切换。
defaultValue、defaultChecked只定义初始值,不持续控制当前 DOM Value。- Dirty 表示当前值偏离初始基线,Touched 表示用户已与字段交互,两者不是同一概念。
- 同步校验应是纯函数;异步校验必须处理 Debounce、Abort、乱序和服务端最终权威。
- 跨字段校验必须基于同一个 Values Snapshot,不能让各字段各自猜测其他字段状态。
- Submit State 应区分 Validating、Submitting、Success、Error 和 Unknown Result,不能只用一个
loading。 - Reset 必须同时处理 Values、Baseline、Touched、Errors、Async Validation 和 Submit State。
二、先建立完整的表单状态模型
以预订表单为例,输入阶段应保存用户原始 Draft:
type BookingDraft = {
email: string;
guestCount: string;
startDate: string;
endDate: string;
notes: string;
acceptTerms: boolean;
};
type FieldName = keyof BookingDraft;
type FieldErrors = Partial<Record<FieldName, string>>;
type FieldFlags = Partial<Record<FieldName, boolean>>;
type SubmitState =
| { status: 'idle' }
| { status: 'validating' }
| { status: 'submitting'; submissionId: string }
| { status: 'success'; bookingId: string }
| { status: 'error'; message: string }
| { status: 'unknown'; operationId: string };
type BookingFormState = {
initialValues: BookingDraft;
values: BookingDraft;
touched: FieldFlags;
clientErrors: FieldErrors;
serverErrors: FieldErrors;
validating: FieldFlags;
submitCount: number;
submit: SubmitState;
};2.1 为什么数量先保存为字符串
用户输入数字时可能经历 ''、'-'、'1.' 等不完整阶段。若每次 onChange 都立即转换成 Number,会丢失用户输入语义,并把空字符串错误变成 0 或 NaN。
表单 Draft 可以暂时无效;提交前再解析成 Domain Command:
type CreateBookingCommand = {
email: string;
guestCount: number;
startDate: string;
endDate: string;
notes?: string;
acceptTerms: true;
};2.2 Values 与 Meta 分开
- Values:用户当前草稿;
- Initial Values:Dirty 比较和 Reset 基线;
- Touched:错误展示时机;
- Errors:当前已知的校验结果;
- Validating:异步校验是否进行;
- Submit State:整次提交的生命周期。
这些状态相关但不能合并成一个 Boolean。例如字段可以 Dirty 但未 Touched,也可以 Touched 但重新改回初始值后不再 Dirty。
三、Controlled Input:React State 是当前事实
受控输入通过 value 或 checked 指定当前值:
function EmailField() {
const [email, setEmail] = useState('');
return (
<label>
邮箱
<input
name="email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</label>
);
}React 会强制 DOM Input 显示 value。因此 onChange 必须同步更新对应 State;如果延迟更新、更新到别的值或忘记更新,输入会回退,看起来像“无法键入”。
3.1 Checkbox 和 Radio 使用 checked
<input
name="acceptTerms"
type="checkbox"
checked={values.acceptTerms}
onChange={(event) => {
setValues((current) => ({
...current,
acceptTerms: event.target.checked,
}));
}}
/>Checkbox 的 event.target.value 默认常是字符串 'on',是否选中应读取 checked。
3.2 Select 与多选
<select
name="roles"
multiple
value={roles}
onChange={(event) => {
setRoles(
Array.from(event.target.selectedOptions, (option) => option.value),
);
}}
>
<option value="viewer">查看者</option>
<option value="editor">编辑者</option>
</select>Controlled <select multiple> 的 value 是字符串数组。不要逐个给 <option selected>;由父级 <select value> 统一控制。
3.3 Textarea 不使用 Children 作为初始值
<textarea
name="notes"
value={values.notes}
onChange={(event) => {
setValues((current) => ({
...current,
notes: event.target.value,
}));
}}
/>React 中 <textarea> 使用 value 或 defaultValue,不通过 Children 设置文本。
3.4 不能在生命周期中切换模式
错误示例:
// 初次 Render 时 user 为空,value 是 undefined,输入为 Uncontrolled。
// 数据返回后 value 变成字符串,又切为 Controlled。
<input value={user?.name} onChange={handleNameChange} />修复方式是从第一次 Render 就提供稳定字符串:
<input value={user?.name ?? ''} onChange={handleNameChange} />对 Checkbox 使用稳定 Boolean。不要用 null/undefined 表示受控文本框的空值。
3.5 什么时候适合 Controlled
- 输入会即时驱动其他 UI;
- 需要字符计数、格式提示或条件字段;
- 需要按字段统一校验和错误展示;
- 多字段之间实时联动;
- 需要外部组件控制值;
- 需要精确追踪 Dirty、Touched 和 Draft。
受控并不代表所有值都要提升到页面顶层。State 应放在最低必要共同所有者,避免每次键入重渲染整个页面。
四、Uncontrolled Input:DOM 保存当前值
非受控输入不传 value/checked,浏览器负责当前 Value:
function NewsletterForm() {
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const email = String(formData.get('email') ?? '');
subscribe({ email });
}
return (
<form onSubmit={handleSubmit}>
<label>
邮箱
<input name="email" type="email" defaultValue="" required />
</label>
<button type="submit">订阅</button>
</form>
);
}适合:
- 字段少、只在提交时读取;
- 主要依赖浏览器原生 Constraint Validation;
- 不需要每次键入驱动 React UI;
- 需要与传统 HTML Form、渐进增强或第三方 DOM 控件协作。
4.1 name 是提交协议的一部分
FormData 只收集有 name 的成功控件。还要注意:
- Disabled Control 通常不提交;
- 未选中的 Checkbox 不产生对应 Entry;
- 同名多选项应使用
getAll(); - Value 主要是 String 或 File;
Object.fromEntries(formData)会丢失同名字段的多值语义。
const formData = new FormData(form);
const interests = formData.getAll('interests').map(String);4.2 Ref 适合命令,不适合复制状态
const inputRef = useRef<HTMLInputElement>(null);
function focusEmail() {
inputRef.current?.focus();
}可以用 Ref 聚焦或在必要时读取 DOM,但不要再把 DOM Value 定期复制到 React State,形成两个权威来源。
4.3 File Input
File Input 的文件选择由用户和浏览器管理,不能像普通文本框一样通过文件路径字符串受控:
function AvatarForm() {
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const avatar = formData.get('avatar');
if (!(avatar instanceof File) || avatar.size === 0) {
return;
}
uploadAvatar(avatar);
}
return (
<form onSubmit={handleSubmit}>
<input name="avatar" type="file" accept="image/png,image/jpeg" />
<button type="submit">上传</button>
</form>
);
}accept 只帮助用户选择,不是安全校验。服务端仍必须检查 MIME、文件内容、大小和权限。若用 URL.createObjectURL() 预览,替换文件或卸载时要调用 URL.revokeObjectURL()。
4.4 一个表单可以混合两种模式
例如搜索关键词受控以驱动建议列表,附件使用非受控 File Input。选择应按字段交互需求,而不是要求整个 Form 只能采用一种模式。
五、Default Value:初始值不是当前值
<input name="displayName" defaultValue="Ada" />
<input name="subscribed" type="checkbox" defaultChecked />defaultValue 和 defaultChecked 只声明初始状态。用户编辑后,父组件再次传入新的 Default Value,不能被理解为强制更新当前输入。
5.1 编辑实体切换问题
function UserForm({ user }: { user: User }) {
return <input name="name" defaultValue={user.name} />;
}如果组件身份不变,只把 user 从 A 换成 B,输入可能保留 A 的 DOM Value。可选策略:
- 用业务 Key 创建新的表单身份;
- 显式执行 Reset;
- 改为 Controlled,并在实体切换事件中重建 Draft。
<UserForm key={user.id} user={user} />Key Remount 会同时清除焦点、选择区、Touched、异步请求和子组件 State,适合“这是全新编辑会话”的场景。若需要保留部分交互,应使用显式 Reset Protocol。
5.2 useState(initialValue) 也只初始化一次
const [name, setName] = useState(user.name);user.name 后续变化不会自动重置 State。不要用一个 Effect 在每次 Query Refetch 时覆盖 Draft,否则后台刷新可能抹掉用户输入。
更合理的规则是:
- 表单未 Dirty 时,可以提示或接受新 Baseline;
- 表单已 Dirty 时,保留 Draft,并提示服务器数据已变化;
- 切换到不同 Entity ID 时,显式开始新会话;
- 保存成功后,用确认响应建立新 Baseline。
六、Dirty / Touched:变化和交互不是一回事
6.1 Dirty
Dirty 表示当前值与 Initial Baseline 不同:
function isFieldDirty<K extends FieldName>(
field: K,
values: BookingDraft,
initialValues: BookingDraft,
): boolean {
return values[field] !== initialValues[field];
}字段改动后再改回初始值,通常应恢复为 Not Dirty。hasChangedOnce 是另一种审计语义,不要与 Dirty 混用。
6.2 比较前是否 Normalize
是否把 ' Ada ' 与 'Ada' 视为相同,要由业务定义:
- 显示名称可能提交前 Trim;
- 密码不能擅自 Trim;
- 电话号码可能规范化;
- 金额字符串需要精确定点解析;
- 数组顺序是否重要取决于领域。
Dirty 比较应与最终提交规范一致,否则 UI 会显示“未修改”,服务端却收到不同值。
6.3 Touched
Touched 常在 Blur 后置为 true:
<input
value={values.email}
onChange={(event) => updateField('email', event.target.value)}
onBlur={() => {
setTouched((current) => ({ ...current, email: true }));
}}
/>Touched 的目标通常是控制错误何时展示,避免页面首次打开就出现一片红色。提交尝试后,可以把所有相关字段视为 Touched 或用 submitCount > 0 统一展示错误。
6.4 常见显示规则
const showEmailError =
Boolean(clientErrors.email) &&
(Boolean(touched.email) || submitCount > 0);对于高风险即时约束,例如超出最大字符数,可以立即提示;普通必填错误通常在 Blur 或 Submit 后显示。错误时机属于 UX Contract,应全产品一致。
七、Sync Validation:纯函数验证一个 Snapshot
同步校验适合:
- 必填;
- 长度和格式;
- 数字范围;
- 本地可判断的业务约束;
- 跨字段关系。
function validateBooking(values: BookingDraft): FieldErrors {
const errors: FieldErrors = {};
if (!values.email.trim()) {
errors.email = '请输入邮箱';
} else if (!isEmail(values.email)) {
errors.email = '邮箱格式不正确';
}
const guestCount = Number(values.guestCount);
if (!Number.isInteger(guestCount) || guestCount < 1 || guestCount > 8) {
errors.guestCount = '人数必须是 1 到 8 的整数';
}
if (!values.acceptTerms) {
errors.acceptTerms = '请先同意预订条款';
}
return errors;
}Validation Function 应:
- 不修改 Values;
- 不执行 Fetch;
- 对相同 Snapshot 返回相同结果;
- 返回结构化错误,而不是直接操作 DOM;
- 可以独立单元测试。
7.1 校验时机
| 时机 | 优点 | 代价 |
|---|---|---|
| Change | 反馈及时 | 容易在输入中间态制造噪声 |
| Blur | 减少打扰 | 用户离开字段后才发现 |
| Submit | 实现简单 | 反馈较晚 |
| 混合 | 首次 Blur 后随 Change 更新 | 状态规则更复杂 |
常见策略是:首次 Blur 或 Submit 后展示错误,之后该字段 Change 时实时更新已显示错误。
7.2 浏览器原生 Constraint Validation
<input
name="email"
type="email"
required
maxLength={120}
/>原生 required、type、min、max、minLength、maxLength 和 pattern 可以减少重复代码,并提供基础键盘与浏览器集成。
如果使用 noValidate 关闭浏览器提示,就必须完整承担错误展示、焦点和可访问性。客户端原生校验仍不能替代服务端验证。
八、Async Validation:处理取消和乱序
用户名可用性、优惠码和地址服务常需要异步验证。最危险的实现是每次键入直接 Fetch,并让最后完成的响应覆盖当前错误。
8.1 Debounce + Abort + Generation
function UsernameField() {
const [username, setUsername] = useState('');
const [error, setError] = useState<string>();
const [validating, setValidating] = useState(false);
const generationRef = useRef(0);
useEffect(() => {
const normalized = username.trim();
if (normalized.length < 3) {
generationRef.current += 1;
setValidating(false);
setError(normalized ? '用户名至少需要 3 个字符' : undefined);
return;
}
const generation = ++generationRef.current;
const controller = new AbortController();
setValidating(false);
const timer = window.setTimeout(async () => {
setValidating(true);
try {
const available = await checkUsername(
normalized,
controller.signal,
);
if (generation !== generationRef.current) return;
setError(available ? undefined : '该用户名已被使用');
} catch (requestError) {
if (controller.signal.aborted) return;
if (generation !== generationRef.current) return;
setError('暂时无法验证用户名,请稍后重试');
} finally {
if (generation === generationRef.current) {
setValidating(false);
}
}
}, 300);
return () => {
window.clearTimeout(timer);
controller.abort();
if (generationRef.current === generation) {
generationRef.current += 1;
}
};
}, [username]);
return (
<label>
用户名
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
aria-invalid={Boolean(error)}
aria-describedby={error ? 'username-error' : undefined}
/>
{validating && <span role="status">正在验证</span>}
{error && <span id="username-error">{error}</span>}
</label>
);
}三层保护职责不同:
- Debounce 减少请求数量;
- Abort 尝试停止旧请求;
- Generation 防止不支持取消或已完成请求提交旧结果。
8.2 异步通过不代表提交一定成功
用户名在校验后到提交前仍可能被其他用户占用。异步字段校验只是提前反馈,服务端提交必须在事务或唯一约束下再次验证。
8.3 何时不做字段异步校验
- 校验成本很高;
- 结果变化太快;
- 会泄露账号是否存在等敏感信息;
- 提交接口已经能快速返回结构化字段错误;
- 校验需要完整表单上下文。
此时在 Submit 后处理服务端错误可能更正确。
九、Cross-field Validation:一次验证同一份 Values
结束日期必须晚于开始日期,确认密码必须等于密码,折扣与商品类型存在组合约束。这些错误不属于单个输入的孤立事实。
function validateDateRange(values: BookingDraft): FieldErrors {
if (!values.startDate || !values.endDate) return {};
const start = parseDateOnly(values.startDate);
const end = parseDateOnly(values.endDate);
if (!start || !end) return {};
if (end < start) {
return {
endDate: '结束日期不能早于开始日期',
};
}
return {};
}9.1 错误挂在哪里
- 能由某个字段修复时,挂到该字段,例如
endDate; - 多字段共同导致且无法归属时,使用 Form-level Error;
- 同时提供字段提示和顶部摘要时,二者应来自同一错误对象。
9.2 依赖字段变化要重新验证
如果用户已经 Touch endDate,随后修改 startDate,日期范围错误必须重新计算。不要只在 endDate.onChange 中校验,否则错误会过期。
最稳妥的方法是让同步 Validator 每次接收完整 Values Snapshot,再按显示策略选择哪些错误呈现。
9.3 Date 与 Time Zone
<input type="date"> 提交的是日期字符串。把它转换为 JavaScript Date 时会引入时区语义,可能让本地日期偏移。Date-only、Local DateTime 和 UTC Instant 是不同领域类型,必须在 Parse 层明确。
十、Submit State:一次提交是一段异步协议
10.1 传统 onSubmit 实现
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submit.status === 'submitting') return;
const snapshot = values;
const errors = validateBooking(snapshot);
setSubmitCount((count) => count + 1);
setClientErrors(errors);
if (Object.keys(errors).length > 0) {
focusFirstInvalidField(event.currentTarget, errors);
return;
}
const submissionId = crypto.randomUUID();
setSubmit({ status: 'submitting', submissionId });
setServerErrors({});
try {
const command = parseBookingCommand(snapshot);
const booking = await createBooking(command, submissionId);
setSubmit({ status: 'success', bookingId: booking.id });
resetToConfirmedBooking(booking);
} catch (error) {
if (error instanceof ValidationError) {
setServerErrors(error.fieldErrors);
setSubmit({ status: 'error', message: '请修正表单内容' });
return;
}
if (error instanceof UnknownResultError) {
setSubmit({ status: 'unknown', operationId: submissionId });
return;
}
setSubmit({ status: 'error', message: '提交失败,请稍后重试' });
}
}这里的 snapshot 保证本次 Validation 和 Command Parse 针对同一份 Values。真实实现还要处理组件卸载后的 UI 回写、当前编辑会话是否仍相同,以及服务端 Mutation 的 Idempotency。
10.2 Pending UI
- 禁用会产生重复副作用的 Submit;
- 保持字段内容可读,不要用 Loading 覆盖整张 Form;
- 明确“正在提交”而不是只改变按钮颜色;
- 允许取消只代表停止等待时,要明确服务端可能已经执行;
- Timeout 后显示“结果确认中”,不要直接诱导新操作。
Button Disabled 只能改善当前页面交互,不能替代服务端 Idempotency Key。
10.3 React 19 Form Action
现代 React 支持把函数传给 <form action>,并通过 useFormStatus 在子组件读取 Pending:
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '保存中' : '保存'}
</button>
);
}
function ProfileForm() {
async function saveProfile(formData: FormData) {
const displayName = String(formData.get('displayName') ?? '');
await updateProfile({ displayName });
}
return (
<form action={saveProfile}>
<input name="displayName" />
<SubmitButton />
</form>
);
}函数 Action 成功后,React 会重置表单中的非受控字段。Controlled Field 仍由 React State 决定,必须显式更新 State。Server Function、Progressive Enhancement 和错误序列化属于框架与部署边界,应按当前版本验证。
需要保存 Action 返回的结构化成功或错误状态时,可以使用 useActionState;需要让 Submit Button 等后代读取所属 Form 的 Pending 时,使用 useFormStatus。两者不能替代服务端字段校验、幂等和权限检查。
十一、Server Error Mapping
客户端校验提升反馈速度,服务端校验才是权威。API 应返回稳定错误码和字段路径,而不是让前端解析自然语言:
type ServerValidationIssue = {
path: 'email' | 'guestCount' | 'startDate' | 'endDate' | '_form';
code: string;
message: string;
};映射规则:
- 已知字段路径进入
serverErrors; _form或未知路径进入 Form-level Error;- 用户修改对应字段后,可以清除该字段旧 Server Error;
- 不应因任意字段变化清除所有服务端错误;
- Error Message 是否直接使用服务端文本取决于本地化和安全策略。
不要把数据库字段名、堆栈、SQL 或敏感业务规则直接显示给用户。
十二、Reset:重置的是完整编辑会话
12.1 Controlled Form Reset
function resetForm(nextInitialValues: BookingDraft) {
setInitialValues(nextInitialValues);
setValues(nextInitialValues);
setTouched({});
setClientErrors({});
setServerErrors({});
setValidating({});
setSubmitCount(0);
setSubmit({ status: 'idle' });
}注意 nextInitialValues 应作为不可变 Snapshot 使用。若后续会修改对象,先做符合数据结构的复制。
12.2 Uncontrolled Form Reset
function ResetButton() {
return <button type="reset">恢复初始值</button>;
}浏览器 form.reset() 会把非受控控件恢复到它们的 Default Value/Default Checked。它不会自动清理你另存在 React State 中的 Error、Touched 和 Submit Message,因此仍需监听 Reset:
<form
onReset={() => {
setErrors({});
setTouched({});
setSubmit({ status: 'idle' });
}}
>12.3 Reset 的不同语义
| 事件 | Reset 目标 |
|---|---|
| 用户点击“撤销修改” | 回到当前 Initial Baseline |
| 切换编辑实体 | 建立新实体的新 Baseline |
| 提交成功 | 用服务器确认结果建立新 Baseline |
| 关闭后重新打开 | 取决于产品是否保留 Draft |
| Query 后台刷新 | Dirty 时通常不应自动覆盖 |
| Logout/权限变化 | 清理敏感 Draft 和文件引用 |
“清空”与“重置”也不同:清空把字段变为空,重置回到 Baseline。
12.4 Reset 与进行中的异步校验
Reset 时应增加 Validation Generation、Abort 当前请求并清理 Validating。否则旧校验响应可能在 Reset 后重新写入 Error。
提交请求若已发送,Reset UI 不代表取消服务端 Mutation。两者必须分开处理。
十三、Server State 与 Draft 的协作
不要直接编辑 Query Cache 中的 Entity Object:
- 其他组件会看到半成品;
- 取消编辑无法恢复;
- Background Refetch 会产生竞态;
- Mutation Rollback 粒度不清。
进入编辑页时从 Server Snapshot 创建 Draft。提交成功后,用服务端确认响应更新 Query Cache,并把该响应作为新的 Form Baseline。
如果服务器 Version 在编辑期间变化,应使用 ETag/Version 检测冲突,并保留 Local Draft,不要静默 Last-write-wins。
十四、性能:先缩小更新范围
Controlled Input 每次键入都会更新 State。如果 State 位于大型页面顶部,整个子树可能重新 Render。
优化顺序:
- 把 Form State 下沉到表单子树;
- 把大型静态区域移出频繁更新组件;
- 按字段或分区拆分订阅;
- 将昂贵派生 UI 与输入更新解耦;
- 大型表单再考虑 Uncontrolled Model 或成熟表单库;
- 在生产构建和目标设备上用 React Profiler 测量。
14.1 不要把输入更新放进 Transition
受控输入的 Backing State 必须同步更新。可以对依赖输入的昂贵结果使用 useDeferredValue:
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
<input value={query} onChange={(event) => setQuery(event.target.value)} />
<ExpensivePreview query={deferredQuery} />Input 本身保持同步,Preview 可以使用较旧值后台 Render。
14.2 不要用 Memoization 掩盖错误所有权
先检查是否把 Form State 提升过高、Context Value 是否包含整张 Form、Validator 是否在每次 Render 重建大对象。memo、useMemo 和 useCallback 都有比较与维护成本,应以 Profiler 证据决定。
十五、可访问性与交互契约
<label htmlFor="email">邮箱</label>
<input
id="email"
name="email"
value={values.email}
onChange={handleEmailChange}
aria-invalid={Boolean(visibleErrors.email)}
aria-describedby={visibleErrors.email ? 'email-error' : undefined}
/>
{visibleErrors.email && (
<p id="email-error">{visibleErrors.email}</p>
)}工程要求:
- 每个输入有可感知 Label;
- 错误通过
aria-describedby与字段关联; - 无效时设置
aria-invalid; - Submit 失败后聚焦第一个错误或错误摘要;
- 异步 Pending/结果使用适度的 Live Region,避免每次键入反复播报;
- 不只用颜色表达 Error、Dirty 或 Success;
- Enter Submit、键盘顺序和浏览器 Autofill 正常工作;
- Disabled 与
aria-disabled的行为差异符合需求。
不要为了自定义样式把原生 Input、Button、Label 全部替换成无语义 div。
十六、测试表单状态
16.1 Controlled 与 Uncontrolled
- Controlled Field 输入后 State 与 DOM 一致;
undefined数据不会触发模式切换;- Checkbox 使用
checked; - FormData 正确读取 String、File 和多值字段;
- Disabled、Unchecked 和无
name字段行为符合提交契约。
16.2 Dirty 与 Touched
- 初始时两者为 False;
- Change 后 Dirty;
- 改回 Baseline 后 Not Dirty;
- Blur 后 Touched;
- Submit 后未触碰字段也显示必要 Error;
- Reset 后 Meta 全部清理。
16.3 Validation
- Sync Validator 覆盖边界值和临时无效值;
- Cross-field Error 随任一依赖字段变化更新;
- Async Validation 会 Debounce;
- 新请求会取消或淘汰旧结果;
- Abort 不显示普通错误;
- Async Pass 后服务端仍可返回冲突错误。
16.4 Submit 与 Reset
- Invalid Form 不调用 Mutation;
- Pending 时快速双击只产生预期逻辑提交;
- Server Field Error 映射正确;
- Timeout 进入 Unknown,而非误报未执行;
- Success 使用 Canonical Response 建立 Baseline;
- Reset 会清 Values、Error、Touched、Validating 和 Submit State;
- 切换 Entity 不会带入上一实体 Draft。
使用 Testing Library 按用户行为输入、Blur 和 Submit,不要只调用内部 Setter。异步网络用 MSW、测试服务器或等价边界模拟延迟、乱序和取消。
十七、常见误区
17.1 所有表单都必须 Controlled
错误。简单一次提交可以充分利用 Uncontrolled Input、FormData 和浏览器原生能力。选择依据是交互与协调需求。
17.2 defaultValue 会随 Props 持续更新
错误。它定义初始值,不控制当前值。切换实体应使用新身份、显式 Reset 或 Controlled Draft。
17.3 Dirty 与 Touched 是同一个状态
错误。Dirty 表示偏离 Baseline,Touched 表示发生过指定交互。字段可以只满足其中一个。
17.4 输入时直接把所有值 Parse 成 Domain Type
错误。用户需要经历暂时无效状态;应保留 Raw Draft,在 Validation/Submit 边界解析。
17.5 Debounce 已经解决异步校验竞态
错误。Debounce 只减少请求,已发请求仍可能乱序返回,还需要 Abort 和 Generation。
17.6 客户端校验通过就可以信任数据
错误。客户端代码可绕过,数据也可能在校验后变化。服务端必须再次验证、授权并保证唯一约束。
17.7 Submit Button Disabled 就不会重复创建
错误。刷新、跨 Tab、代理 Retry 和响应丢失仍可能重复提交,服务端需要 Idempotency。
17.8 form.reset() 会清除所有 React 状态
错误。它主要重置原生控件;另存的 Error、Touched、Async State 和 Controlled Values 仍需显式处理。
17.9 Query Refetch 后应始终覆盖表单
错误。Dirty Draft 是独立编辑会话,应提示冲突、合并或由用户选择,而不是静默丢弃。
十八、工程方案选择
| 场景 | 建议状态策略 |
|---|---|
| 简单订阅表单 | Uncontrolled + FormData + 原生校验 |
| 搜索建议 | Controlled Query + Debounced Async Result |
| 登录表单 | 可 Controlled 或 Uncontrolled;明确 Submit Error |
| 长编辑表单 | 独立 Draft、Dirty/Touched、Server Version |
| 文件上传 | File Input Uncontrolled,React 管理元数据与进度 |
| 多字段实时联动 | Controlled 或具备细粒度订阅的表单库 |
| 高风险提交 | 状态机 + Idempotency + Unknown Result |
| SSR 初始编辑 | Server Snapshot 初始化 Draft,Hydration 后不自动覆盖 |
具体表单库选型、Schema Validation、Dynamic Fields 和 Field Array 将在“表单工程”模块展开。
十九、工程检查清单
- Form State 是否与 Server State 分离;
- 每个字段由 React 还是 DOM 控制是否明确;
- Controlled Value 是否从首个 Render 就保持稳定类型;
- Checkbox/Radio 是否使用
checked; defaultValue是否只承担初始化;- Numeric/Date Draft 是否允许中间无效值;
- Initial Baseline 和当前 Values 是否同时存在;
- Dirty 与 Touched 是否分开定义;
- Sync Validator 是否纯净且可测试;
- Async Validator 是否有 Debounce、Abort、Generation 和 Cleanup;
- Cross-field Validation 是否读取统一 Snapshot;
- Client 与 Server Error 是否能稳定映射;
- Submit 是否区分 Validating、Pending、Error 和 Unknown;
- 重复提交是否有服务端幂等;
- Reset 是否覆盖 Values、Meta、Error、Async 和 Submit State;
- Dirty Form 遇到 Server Refetch 是否避免静默覆盖;
- Label、Error Association、Focus 和 Live Region 是否可访问;
- 性能结论是否经过生产构建和目标设备测量。
二十、总结
- Form State 是一个有独立生命周期的本地 Draft,不是服务器实体本身。
- Controlled Input 由 React State 决定当前值,Uncontrolled Input 由 DOM 决定当前值。
- 同一字段不能在生命周期中切换 Controlled/Uncontrolled Mode。
- Default Value 只负责初始化;实体切换和服务器更新需要显式 Reset Policy。
- Dirty 关注当前值与 Baseline,Touched 关注用户交互,两者服务不同 UI 决策。
- Sync Validation 应是纯函数,并允许输入过程中的暂时无效状态。
- Async Validation 必须同时治理请求频率、取消、乱序和服务端最终校验。
- Cross-field Validation 应验证同一 Values Snapshot,并随所有依赖字段更新。
- Submit 是带校验、错误、幂等和未知结果的异步状态机。
- Reset 不是简单清空 Input,而是重新建立 Values、Baseline 和全部 Meta 的一致状态。
表单设计真正要维护的是一份可解释的编辑会话:用户当前写了什么、哪些内容偏离初始事实、哪些错误已经值得展示、服务端确认了什么,以及取消、失败或成功后应回到哪一个稳定基线。
问答复盘
Q1:Controlled Input 与 Uncontrolled Input 的根本区别是什么?
答: 当前值的权威所有者不同。Controlled 由 React State 决定,Uncontrolled 由 DOM 保存并通常通过 FormData 或 Ref 读取。
Q2:为什么 value={user?.name} 容易产生模式切换警告?
答: 初次 Render 可能是 undefined,输入被视为 Uncontrolled;数据返回后变成字符串又转为 Controlled。应从一开始使用 user?.name ?? '' 等稳定值。
Q3:修改 defaultValue 为什么没有更新用户当前看到的文本?
答: defaultValue 只定义初始值,不持续控制 DOM。需要新编辑会话时应更换 Key、显式 Reset 或改为 Controlled。
Q4:字段已经 Dirty,是否一定 Touched?
答: 不一定。程序更新可以让字段 Dirty 但未发生 Blur;用户 Focus/Blur 后不修改,则 Touched 但不 Dirty。
Q5:为什么数量字段在编辑阶段建议保存字符串?
答: 用户输入会经过空值和不完整数字等中间态。保存字符串能保留 Draft 语义,提交前再校验并转换成 Domain Number。
Q6:Debounce 能否防止旧异步校验覆盖新值?
答: 不能完全防止。它只减少请求;仍需 Abort 旧请求并用 Generation/Request ID 忽略迟到结果。
Q7:跨字段错误应该挂到哪个字段?
答: 若一个字段可直接修复,就挂到该字段;无法单字段归属时使用 Form-level Error。所有错误应来自同一次完整 Snapshot 校验。
Q8:React 19 函数式 Form Action 成功后会重置 Controlled Input 吗?
答: 不会自动改变其 React State。成功后会重置非受控字段;Controlled Field 仍需显式更新 Backing State。
Q9:点击 Reset 后为何旧异步错误又出现?
答: Reset 只改了 Values,没有 Abort 请求或推进 Validation Generation。迟到响应仍被接受,应在 Reset 时同时清理异步生命周期。
Q10:服务端 Query 在编辑期间刷新,应该直接覆盖 Draft 吗?
答: Dirty 时通常不应。应保留用户 Draft,比较服务端 Version,并提示重新加载、合并或解决冲突。
延伸知识
- 表单工程:React Hook Form、Formik、Schema Validation、Zod、Dynamic Fields 和 Field Array。
- Mutation:Idempotency、Optimistic Update、Server Error Mapping 和 Conflict Resolution。
- 状态分类:Form State、Server State、URL State 与 Single Source of Truth。
- 组件 API:Controlled/Uncontrolled Component、Default Props 和状态所有权。
- 可访问性:Label、Constraint Validation、Error Summary、Focus Management 和 Live Region。
- 浏览器表单协议:FormData、Constraint Validation API、Autofill、File Input 和 Native Submit。