React 表单实战:受控输入、校验与提交状态
实现一个带字段错误提示和异步提交状态的受控表单,理解字符串状态、原生表单语义、可访问性和前后端校验边界。
表单的难点通常不在输入框本身,而在状态边界:什么时候校验、如何显示错误、提交期间能不能再次点击、失败后是否保留输入。
先把这些问题处理清楚,再考虑是否引入表单库。
1. 受控输入要有稳定的值类型
const [email, setEmail] = useState("");
<input
type="email"
value={email}
onChange={event => setEmail(event.target.value)}
/>
输入框显示的内容由 React state 决定。初始化为 "",而不是 undefined,可以避免输入框从非受控切换为受控。
数字输入也常适合先保存字符串,因为编辑过程中可能出现空值、小数点或暂时不完整的数字。等校验或提交时再转换,并明确 Number("") 会得到 0,不代表用户真的输入了 0。
2. 把业务提交作为组件依赖
下面实现一个订阅表单,通过 onSubscribe 接收异步业务函数。这样组件不假设某个 API 已经存在,也更容易测试成功与失败分支。在 Next.js App Router 中,包含事件和 Hook 的组件需要处于客户端组件边界内。
import { useId, useRef, useState } from "react";
import type { FormEvent } from "react";
type SubscribeFormProps = {
onSubscribe: (email: string) => Promise<void>;
};
function validateEmail(email: string): string {
if (!email) return "请输入邮箱地址";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return "请输入有效的邮箱地址";
}
return "";
}
export function SubscribeForm({ onSubscribe }: SubscribeFormProps) {
const emailId = useId();
const errorId = `${emailId}-error`;
const [email, setEmail] = useState("");
const [fieldError, setFieldError] = useState("");
const [submitError, setSubmitError] = useState("");
const [status, setStatus] = useState<"idle" | "submitting" | "success">("idle");
const submittingRef = useRef(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submittingRef.current) return;
const normalizedEmail = email.trim();
const validationError = validateEmail(normalizedEmail);
setFieldError(validationError);
setSubmitError("");
if (validationError) return;
submittingRef.current = true;
setStatus("submitting");
try {
await onSubscribe(normalizedEmail);
setStatus("success");
setEmail("");
} catch {
setStatus("idle");
setSubmitError("订阅失败,请稍后重试");
} finally {
submittingRef.current = false;
}
}
return (
<form onSubmit={handleSubmit} noValidate aria-busy={status === "submitting"}>
<label htmlFor={emailId}>邮箱地址</label>
<input
id={emailId}
name="email"
type="email"
autoComplete="email"
required
value={email}
disabled={status === "submitting"}
aria-invalid={Boolean(fieldError)}
aria-describedby={fieldError ? errorId : undefined}
onChange={event => {
setEmail(event.target.value);
setFieldError("");
setSubmitError("");
setStatus("idle");
}}
/>
{fieldError && <p id={errorId} role="alert">{fieldError}</p>}
<button type="submit" disabled={status === "submitting"}>
{status === "submitting" ? "正在订阅……" : "订阅"}
</button>
{submitError && <p role="alert">{submitError}</p>}
<p role="status">{status === "success" ? "订阅成功!" : ""}</p>
</form>
);
}
onSubscribe 的约定是成功时 resolve、失败时 reject。如果内部使用 fetch,必须检查 response.ok:HTTP 400 或 500 不会自动让 fetch 的 Promise reject。
邮箱正则只是教学用的基础检查,不覆盖全部合法邮箱格式。真实系统应根据业务选择校验策略,并通过确认邮件验证地址归属。
3. 提交状态和同步防重各司其职
status 控制按钮文案、禁用状态和成功提示;ref 则作为不参与渲染的同步锁,避免在 React 提交禁用按钮之前连续进入处理函数。
这不是服务端防重。刷新页面、重试请求或多个客户端仍可能重复提交。涉及支付或创建订单时,服务端还需要幂等机制。
失败时保留邮箱,让用户可以直接重试。提交期间禁用输入,避免提交的是旧邮箱,界面却显示新邮箱的歧义。
4. 不要丢掉原生表单语义
使用 <form onSubmit>,而不是只给按钮绑定 onClick,可以让回车提交等浏览器行为正常工作。按钮显式使用 type="submit";表单里的取消按钮则应使用 type="button"。
示例选择 noValidate,是为了统一显示自定义错误。如果不需要自定义提示,可以保留浏览器约束校验。无论哪种方式,都要保留真实的 label 和清楚的错误说明。
aria-invalid 表明字段有错误,aria-describedby 把错误信息与输入框关联,role="status" 用于宣布成功结果。useId 为不同表单实例生成独立的 ID,避免关联到其他输入框。
5. 客户端校验不是安全边界
客户端校验帮助用户更快修正输入,但请求可以绕过页面直接发送。服务端仍然必须验证字段、限制输入大小,并根据业务处理鉴权、速率限制和重复订阅。
字段错误、网络错误和服务端业务错误最好分开处理。表单复杂到存在大量嵌套字段、动态数组或跨字段规则时,再评估表单库和 schema 校验库。
小结
一个可靠的表单至少需要四件事:明确的数据类型、可理解的校验反馈、完整的提交状态,以及服务端验证。先把这些基础做好,表单库才会成为提升效率的工具,而不是掩盖边界问题的工具。