西瓜博客
返回所有文章

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 校验库。

小结

一个可靠的表单至少需要四件事:明确的数据类型、可理解的校验反馈、完整的提交状态,以及服务端验证。先把这些基础做好,表单库才会成为提升效率的工具,而不是掩盖边界问题的工具。