React 基础体系 · 第 8/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 表单工程:受控组件、校验、异步提交、错误与性能

表单看似只是若干 <input> 和一个提交按钮,实际上同时涉及五类问题:

  1. 数据所有权:输入框中的值由 DOM 保存,还是由 React 状态保存?
  2. 校验时机:什么时候校验,错误显示在哪里,客户端校验和服务端校验如何分工?
  3. 异步流程:提交期间如何防止重复提交、处理网络失败和竞态?
  4. 错误表达:字段错误、表单级错误、权限错误和系统异常如何区分?
  5. 渲染成本:每次击键都触发 React 更新时,如何避免无关组件重复渲染?

本文以 React 19、现代 TypeScript 和主流 React 框架为背景,先建立表单的状态模型,再实现一个可运行的登录表单,最后讨论 React 19 的表单 Action、服务端边界、可访问性和性能取舍。


一、先建立表单的状态模型

1. 受控组件是什么

受控组件是指表单元素的当前值由 React 状态决定:

const [email, setEmail] = useState("");

<input
  value={email}
  onChange={(event) => setEmail(event.target.value)}
/>

数据流是:

用户输入
  ↓
浏览器触发 input/change 事件
  ↓
onChange 读取 event.target.value
  ↓
React 更新 state
  ↓
重新渲染
  ↓
value 再次写入 input

因此,输入框的值不是一个独立于 React 的事实源,而是:

DOM.value=state.emailDOM.value = state.email

在正常更新路径中,这个等式应当成立。

受控组件的直接收益是:渲染、校验、提交、禁用按钮、错误提示都可以读取同一个状态。比如:

<button disabled={email.trim() === ""}>
  提交
</button>

这里的按钮状态不是通过查询 DOM 得到的,而是由 React 状态推导出来的。

2. 非受控组件是什么

非受控组件由 DOM 保存当前值,React 只在需要时通过 refFormData 读取:

const formRef = useRef<HTMLFormElement>(null);

<form ref={formRef}>
  <input name="email" defaultValue="" />
</form>

提交时:

const form = formRef.current;
if (!form) return;

const formData = new FormData(form);
const email = formData.get("email");

defaultValue 只用于初始值;后续输入由浏览器 DOM 管理。它与 value 的区别不是语法偏好,而是所有权不同:

方式 当前值的事实源 常见读取方式
受控 React state 直接读取状态
非受控 DOM refFormData

受控和非受控可以混合使用,但同一个字段不能在生命周期中从一种模式切换到另一种模式。例如:

// 初始时 value 是 undefined,之后变成字符串
<input value={user?.email} onChange={...} />

这可能导致 React 的 “uncontrolled to controlled” 警告。应将空值明确初始化为字符串:

<input
  value={user?.email ?? ""}
  onChange={...}
/>

对于复选框,使用 checked,而不是 value

<input
  type="checkbox"
  checked={remember}
  onChange={(event) => setRemember(event.target.checked)}
/>

3. 受控组件不等于必须把所有字段放进一个对象

以下两种写法都可以:

const [email, setEmail] = useState("");
const [password, setPassword] = useState("");

或者:

type Values = {
  email: string;
  password: string;
};

const [values, setValues] = useState<Values>({
  email: "",
  password: "",
});

多个字段之间存在联动、校验状态较复杂时,useReducer 通常更容易表达状态转换:

type FormState = {
  values: Values;
  touched: Record<keyof Values, boolean>;
  errors: Partial<Record<keyof Values, string>>;
  submitting: boolean;
  serverError: string | null;
};

useReducer 的价值主要是集中描述状态转换,不是自动带来性能提升。它仍然会导致使用该状态的组件重新渲染。


二、状态快照、批处理与事件处理

React 函数组件中的 state 是当前渲染的快照。事件处理函数捕获的是创建它的那次渲染中的值。

function Example() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    setCount(count + 1);
  }

  return <button onClick={handleClick}>{count}</button>;
}

点击一次后通常得到 1,而不是 2。两次调用都读取了同一个快照:

count = 0
第一次 setCount(0 + 1)
第二次 setCount(0 + 1)
最终结果 = 1

如果更新依赖前一个状态,应使用函数式更新:

function handleClick() {
  setCount((previous) => previous + 1);
  setCount((previous) => previous + 1);
}

此时 React 会按顺序计算:

previous = 0 → 1
previous = 1 → 2
最终结果 = 2

React 18 及之后,React 会对更多异步场景中的更新进行批处理;React 19 延续这一模型。批处理意味着多个更新可能合并为一次渲染,但不意味着状态变量会在当前函数中立即改变

表单提交尤其容易出现这个误区:

setValues(nextValues);
await submit(values); // 这里的 values 仍可能是旧快照

正确做法是直接提交已知的 nextValues

const nextValues = {
  ...values,
  email: values.email.trim(),
};

setValues(nextValues);
await submit(nextValues);

或者让提交函数接收明确的数据参数,而不是依赖刚刚排队更新的 state。


三、校验不是一个函数,而是多个边界

1. 校验的形式化模型

对字段值 xx,校验可以看作一个返回错误集合的函数:

V(x)EV(x) \rightarrow E

其中:

  • xx 是字段或表单数据;
  • EE 是错误集合;
  • E=E = \varnothing 表示通过校验;
  • EE \neq \varnothing 表示存在错误。

例如邮箱校验可以写成:

Vemail(x)={{required}x.trim()=""{format}x 不符合邮箱格式其他情况V_{\text{email}}(x) = \begin{cases} \{\text{required}\} & x.trim() = "" \\ \{\text{format}\} & x \text{ 不符合邮箱格式} \\ \varnothing & \text{其他情况} \end{cases}

但这只是客户端可观察的校验。完整的提交条件还包括服务端规则:

Valid(form)=ClientSyntax(form)ServerBusinessRule(form)Permission(form)Valid(form) = ClientSyntax(form) \land ServerBusinessRule(form) \land Permission(form)

客户端只能提前判断第一部分,不能证明整个表达式为真。比如:

  • 邮箱格式正确,但账号不存在;
  • 密码格式正确,但密码错误;
  • 用户有输入权限,但会话已经过期;
  • 表单提交时资源已被其他请求占用。

因此,客户端校验的作用是改善反馈速度,不是建立安全边界。

2. 三类校验时机

输入时校验

每次 onChange 都校验:

onChange={(event) => {
  const value = event.target.value;
  setEmail(value);
  setEmailError(validateEmail(value));
}}

优点是反馈快,缺点是用户还没输入完成时就可能看到错误。例如用户输入 a 时显示邮箱格式错误,体验通常较差。

失焦时校验

onBlur 中标记字段已访问:

onBlur={() => {
  setTouched((previous) => ({
    ...previous,
    email: true,
  }));
}}

渲染时只对已访问字段显示错误:

{touched.email && emailError && (
  <p>{emailError}</p>
)}

这是常见默认策略:输入过程中保持稳定,离开字段后反馈问题。

提交时校验

提交时必须对全部字段校验,即使用户没有逐个失焦:

function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
  event.preventDefault();

  const errors = validateAll(values);

  if (Object.keys(errors).length > 0) {
    // 标记所有相关字段,显示错误
    return;
  }

  // 进入异步提交
}

提交时校验是不可省略的最后一道客户端检查,因为用户可以直接按 Enter 提交,也可能通过脚本触发提交。

3. 原生约束与自定义校验

HTML 原生约束可以表达一部分通用规则:

<input
  name="email"
  type="email"
  required
  autoComplete="email"
/>

<input
  name="password"
  type="password"
  required
  minLength={8}
  autoComplete="current-password"
/>

浏览器会在提交前执行这些约束,并阻止不符合约束的表单提交。自定义错误可以通过 React 自己渲染,也可以使用 setCustomValidity,但两者不要重复制造互相矛盾的错误来源。

如果完全由 React 负责校验,可以使用:

<form noValidate onSubmit={handleSubmit}>

这会关闭浏览器的默认约束提示,但不会删除 requiredtype="email" 等属性本身。关闭后,React 必须负责完整的错误反馈和可访问性。


四、一个完整的受控异步表单

下面实现一个登录表单,包含:

  • 受控输入;
  • useReducer 管理状态;
  • 失焦和提交校验;
  • 网络请求;
  • 4xx/5xx 错误解析;
  • 重复提交保护;
  • 请求竞态保护;
  • 字段错误和表单级错误;
  • 基本可访问性处理。

示例假设服务端接口为:

POST /api/login
Content-Type: application/json

成功响应:

{
  "ok": true,
  "redirectTo": "/dashboard"
}

校验失败响应:

{
  "ok": false,
  "fieldErrors": {
    "email": "账号或密码不正确"
  },
  "formError": "登录失败"
}

1. 类型和校验函数

"use client";

import {
  FormEvent,
  useEffect,
  useReducer,
  useRef,
} from "react";

type Values = {
  email: string;
  password: string;
  remember: boolean;
};

type FieldName = keyof Values;

type FieldErrors = Partial<Record<"email" | "password", string>>;

type FormState = {
  values: Values;
  touched: Partial<Record<FieldName, boolean>>;
  errors: FieldErrors;
  submitting: boolean;
  serverError: string | null;
  succeeded: boolean;
};

const initialState: FormState = {
  values: {
    email: "",
    password: "",
    remember: false,
  },
  touched: {},
  errors: {},
  submitting: false,
  serverError: null,
  succeeded: false,
};

function validateEmail(email: string): string | undefined {
  const value = email.trim();

  if (!value) {
    return "请输入邮箱";
  }

  // 这里只做基础格式校验,不试图完整实现 RFC 邮箱语法。
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
    return "请输入有效的邮箱地址";
  }

  return undefined;
}

function validatePassword(password: string): string | undefined {
  if (!password) {
    return "请输入密码";
  }

  if (password.length < 8) {
    return "密码至少需要 8 个字符";
  }

  return undefined;
}

function validateValues(values: Values): FieldErrors {
  const errors: FieldErrors = {};

  const emailError = validateEmail(values.email);
  const passwordError = validatePassword(values.password);

  if (emailError) {
    errors.email = emailError;
  }

  if (passwordError) {
    errors.password = passwordError;
  }

  return errors;
}

这里没有把 remember 当作错误字段,因为它是可选项。FieldErrors 只允许字段错误映射到 emailpassword,可以避免任意字符串导致的类型失控。

2. Reducer:把状态转换写清楚

type Action =
  | {
      type: "change";
      field: FieldName;
      value: string | boolean;
    }
  | {
      type: "blur";
      field: FieldName;
    }
  | {
      type: "show-validation-errors";
      errors: FieldErrors;
    }
  | {
      type: "submit-start";
    }
  | {
      type: "submit-success";
    }
  | {
      type: "submit-failure";
      fieldErrors?: FieldErrors;
      formError: string;
    };

function reducer(state: FormState, action: Action): FormState {
  switch (action.type) {
    case "change": {
      const values = {
        ...state.values,
        [action.field]: action.value,
      } as Values;

      // 用户修改字段后清除该字段旧的服务端错误或客户端错误。
      const errors = { ...state.errors };
      if (action.field === "email" || action.field === "password") {
        delete errors[action.field];
      }

      return {
        ...state,
        values,
        errors,
        serverError: null,
        succeeded: false,
      };
    }

    case "blur": {
      const touched = {
        ...state.touched,
        [action.field]: true,
      };

      const errors = validateValues(state.values);

      return {
        ...state,
        touched,
        errors,
      };
    }

    case "show-validation-errors":
      return {
        ...state,
        errors: action.errors,
        touched: {
          email: true,
          password: true,
        },
      };

    case "submit-start":
      return {
        ...state,
        submitting: true,
        serverError: null,
        succeeded: false,
      };

    case "submit-success":
      return {
        ...state,
        submitting: false,
        errors: {},
        serverError: null,
        succeeded: true,
      };

    case "submit-failure":
      return {
        ...state,
        submitting: false,
        errors: action.fieldErrors ?? {},
        serverError: action.formError,
        succeeded: false,
      };

    default:
      return state;
  }
}

这里的状态转换有几个重要特点:

  • change 只修改一个字段,但保留其他字段;
  • 字段发生新修改时清除旧错误,避免错误信息滞留;
  • submit-start 不清除字段错误,因为提交前可能已经存在校验问题;
  • submit-failure 同时支持 fieldErrorsformError
  • submitting 是状态的一部分,按钮可以据此禁用。

3. 表单组件

export default function LoginForm() {
  const [state, dispatch] = useReducer(reducer, initialState);

  // 每次提交使用递增序号,防止旧响应覆盖新状态。
  const requestIdRef = useRef(0);
  const abortControllerRef = useRef<AbortController | null>(null);

  const firstInvalidRef = useRef<HTMLInputElement | null>(null);

  useEffect(() => {
    if (state.serverError) {
      firstInvalidRef.current?.focus();
    }
  }, [state.serverError]);

  function handleChange(
    field: FieldName,
    value: string | boolean,
  ) {
    dispatch({
      type: "change",
      field,
      value,
    });
  }

  function handleBlur(field: FieldName) {
    dispatch({
      type: "blur",
      field,
    });
  }

  async function handleSubmit(
    event: FormEvent<HTMLFormElement>,
  ) {
    event.preventDefault();

    // 先对当前渲染快照中的 values 进行同步校验。
    const errors = validateValues(state.values);

    if (Object.keys(errors).length > 0) {
      dispatch({
        type: "show-validation-errors",
        errors,
      });

      return;
    }

    // 如果允许重复提交,则取消之前的请求。
    abortControllerRef.current?.abort();

    const controller = new AbortController();
    abortControllerRef.current = controller;

    const requestId = ++requestIdRef.current;

    dispatch({ type: "submit-start" });

    try {
      const response = await fetch("/api/login", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          email: state.values.email.trim(),
          password: state.values.password,
          remember: state.values.remember,
        }),
        signal: controller.signal,
      });

      // fetch 只会在网络层失败时 reject。
      // HTTP 400、401、500 通常仍然会得到一个 fulfilled Response。
      const payload: {
        ok?: boolean;
        redirectTo?: string;
        fieldErrors?: FieldErrors;
        formError?: string;
      } = await response.json();

      if (requestId !== requestIdRef.current) {
        return;
      }

      if (!response.ok || payload.ok !== true) {
        dispatch({
          type: "submit-failure",
          fieldErrors: payload.fieldErrors,
          formError: payload.formError ?? "登录失败,请稍后重试",
        });
        return;
      }

      dispatch({ type: "submit-success" });

      if (payload.redirectTo) {
        window.location.assign(payload.redirectTo);
      }
    } catch (error) {
      if (requestId !== requestIdRef.current) {
        return;
      }

      if (error instanceof DOMException && error.name === "AbortError") {
        return;
      }

      dispatch({
        type: "submit-failure",
        formError: "网络异常,请检查连接后重试",
      });
    }
  }

  const emailError =
    state.touched.email || state.submitting
      ? state.errors.email
      : undefined;

  const passwordError =
    state.touched.password || state.submitting
      ? state.errors.password
      : undefined;

  return (
    <form
      noValidate
      onSubmit={handleSubmit}
      aria-describedby={state.serverError ? "form-error" : undefined}
    >
      <div>
        <label htmlFor="login-email">邮箱</label>

        <input
          ref={firstInvalidRef}
          id="login-email"
          name="email"
          type="email"
          autoComplete="username"
          value={state.values.email}
          onChange={(event) =>
            handleChange("email", event.target.value)
          }
          onBlur={() => handleBlur("email")}
          aria-invalid={Boolean(emailError)}
          aria-describedby={emailError ? "email-error" : undefined}
        />

        {emailError && (
          <p id="email-error" role="alert">
            {emailError}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="login-password">密码</label>

        <input
          id="login-password"
          name="password"
          type="password"
          autoComplete="current-password"
          value={state.values.password}
          onChange={(event) =>
            handleChange("password", event.target.value)
          }
          onBlur={() => handleBlur("password")}
          aria-invalid={Boolean(passwordError)}
          aria-describedby={
            passwordError ? "password-error" : undefined
          }
        />

        {passwordError && (
          <p id="password-error" role="alert">
            {passwordError}
          </p>
        )}
      </div>

      <label>
        <input
          name="remember"
          type="checkbox"
          checked={state.values.remember}
          onChange={(event) =>
            handleChange("remember", event.target.checked)
          }
        />
        记住我
      </label>

      {state.serverError && (
        <p id="form-error" role="alert">
          {state.serverError}
        </p>
      )}

      {state.succeeded && (
        <p role="status">登录成功,正在跳转……</p>
      )}

      <button type="submit" disabled={state.submitting}>
        {state.submitting ? "提交中……" : "登录"}
      </button>
    </form>
  );
}

4. 这段提交流程的中间状态

一次合法提交的状态变化是:

初始:
submitting = false
errors = {}
serverError = null

用户点击提交:
1. validateValues(values)
2. dispatch(submit-start)
3. submitting = true
4. 发起 fetch

服务端成功:
5. dispatch(submit-success)
6. submitting = false
7. succeeded = true
8. 跳转

服务端返回业务错误:
5. dispatch(submit-failure)
6. submitting = false
7. errors = fieldErrors
8. serverError = formError

如果同步校验失败,则不会进入网络阶段:

validateValues(values) → { email: "请输入邮箱" }
↓
show-validation-errors
↓
标记字段 touched
↓
显示字段错误
↓
不发送请求

五、异步提交中的错误、取消与竞态

1. fetch 不会因 4xx 或 5xx 自动抛错

这是常见误解:

try {
  const response = await fetch("/api/login");

  // 即使 response.status 是 401,也通常不会进入 catch。
} catch {
  // 这里主要处理 DNS 失败、断网、请求被取消等网络层异常。
}

因此必须显式检查:

if (!response.ok) {
  // 处理 HTTP 错误
}

可以将错误分成四层:

层级 示例 用户界面
字段错误 邮箱格式不合法 显示在对应字段旁
表单错误 邮箱或密码错误 表单级错误
传输错误 断网、超时、请求取消 网络提示和重试
系统错误 服务端 500、未知异常 通用错误,不暴露内部细节

不要把异常堆栈、SQL 错误或内部服务名直接返回给浏览器。服务端应记录详细日志,客户端只接收适合用户理解的错误码或消息。

2. 为什么需要请求序号

考虑两个请求:

t1: 请求 A 发出
t2: 请求 B 发出
t3: 请求 B 返回成功
t4: 请求 A 返回失败

如果响应处理函数不检查来源,A 的旧结果可能覆盖 B 的新结果。请求序号建立了一个简单条件:

只有response.requestId=currentRequestId时,才允许更新UI只有 response.requestId = currentRequestId 时,才允许更新 UI

这就是示例中:

const requestId = ++requestIdRef.current;

if (requestId !== requestIdRef.current) {
  return;
}

AbortController 是资源和体验层面的补充:它可以取消仍在进行的请求,但取消并不能保证服务端已经撤销了操作。对支付、创建订单等不可逆操作,服务端还需要幂等键,而不能只依赖前端取消。

3. 超时不是浏览器 fetch 的默认行为

fetch 没有一个普遍可用的默认业务超时。可以使用 AbortSignal.timeout

const response = await fetch("/api/login", {
  method: "POST",
  body: JSON.stringify(values),
  headers: {
    "Content-Type": "application/json",
  },
  signal: AbortSignal.timeout(10_000),
});

这是现代浏览器能力,但应根据目标浏览器和框架运行环境确认兼容性。也可以手动创建 AbortControllersetTimeout,以便兼容更广泛的运行环境。


六、异步字段校验:必须处理竞态

例如用户名可用性检查:

useEffect(() => {
  const username = values.username.trim();

  if (username.length < 3) {
    return;
  }

  const controller = new AbortController();

  async function checkAvailability() {
    try {
      const response = await fetch(
        `/api/users/check?name=${encodeURIComponent(username)}`,
        { signal: controller.signal },
      );

      const result: { available: boolean } =
        await response.json();

      // 只有当前 effect 对应的 username 才能更新状态。
      if (result.available) {
        // 清除“用户名已存在”错误
      } else {
        // 设置“用户名已存在”错误
      }
    } catch (error) {
      if (
        error instanceof DOMException &&
        error.name === "AbortError"
      ) {
        return;
      }

      // 处理网络错误
    }
  }

  checkAvailability();

  return () => {
    controller.abort();
  };
}, [values.username]);

这里的因果关系是:

  1. values.username 变化;
  2. 旧 effect 的清理函数执行;
  3. 旧请求被取消;
  4. 新 effect 发起新请求;
  5. 新结果只作用于当前输入值。

如果不取消或不检查版本,用户快速输入 alicealice1 时,旧请求可能晚于新请求返回,造成错误提示与输入内容不一致。

异步校验还应避免每个字符都请求服务端。常见方法是:

  • 只在长度达到最低要求后请求;
  • 使用防抖;
  • 服务端仍然在最终提交时再次校验;
  • 将“检查中”作为独立状态,而不是把它误当作“通过”。

七、错误反馈与可访问性

1. 标签必须与控件关联

<label htmlFor="login-email">邮箱</label>
<input id="login-email" name="email" />

只把文字放在输入框旁边但不使用 label,会损害屏幕阅读器和点击标签聚焦输入框的行为。

2. aria-invalid 表示当前字段有错误

<input
  aria-invalid={Boolean(emailError)}
  aria-describedby={emailError ? "email-error" : undefined}
/>

<p id="email-error" role="alert">
  请输入有效的邮箱地址
</p>

aria-describedby 将输入框和错误文本关联起来。错误文本应具有稳定的 id,不要依赖随机生成的字符串,否则服务端渲染和客户端水合时可能出现不一致。

aria-invalid 不应在用户刚打开空表单时就无条件设置为 true。通常应在字段失焦、用户修改后或提交失败后设置,否则用户还没有操作就会得到大量错误提示。

3. 提交失败后管理焦点

提交失败时,用户需要知道错误发生在哪里。可以:

  • 聚焦错误摘要;
  • 如果没有摘要,聚焦第一个错误字段;
  • 保留错误文本;
  • 不要仅依赖颜色表达错误。

简单错误摘要可以是:

{hasErrors && (
  <div tabIndex={-1} role="alert" ref={summaryRef}>
    表单中有错误,请检查标记的字段。
  </div>
)}

tabIndex={-1} 允许程序聚焦,但不会把摘要加入正常 Tab 顺序。动态内容用 role="alert"role="status" 时应谨慎,避免每次击键都触发屏幕阅读器播报。

4. 键盘提交依赖正确的语义结构

应使用:

<form onSubmit={handleSubmit}>
  <button type="submit">提交</button>
</form>

而不是只给一个按钮绑定 onClickform 的语义可以支持:

  • 键盘 Enter 提交;
  • 浏览器原生约束;
  • 辅助技术识别表单;
  • FormData 收集字段;
  • React 表单 Action。

八、服务端边界:客户端校验永远不是安全校验

如果组件位于 Next.js 等框架的客户端边界中,"use client" 组件可以读取用户输入、调用浏览器 API 和执行 fetch。但服务端接口仍必须独立完成:

  1. 解析请求体;
  2. 校验类型和长度;
  3. 验证身份和权限;
  4. 防止 CSRF 或使用同源安全策略;
  5. 执行业务规则;
  6. 控制速率;
  7. 记录安全审计信息;
  8. 返回不泄露敏感信息的错误。

服务端不能相信:

if (clientErrorsAreEmpty) {
  // 服务端直接写数据库
}

正确逻辑是服务端再次执行校验:

客户端快速校验
  ↓
减少明显错误请求
  ↓
服务端解析并重新校验
  ↓
验证认证、权限、业务条件
  ↓
执行操作

密码字段不应写入日志。服务端错误日志也应避免记录完整请求体、密码、令牌和敏感个人信息。


九、React 19 的表单 Action

React 19 增加了与表单 Action 相关的能力,包括 useActionStateuseFormStatus 等。它们适合将“提交状态、返回状态、pending 状态”与表单提交流程关联起来,但服务端 Action 是否可用取决于所使用的框架和构建环境。React 本身不是后端,也不会自动提供数据库接口或 API 路由。

1. useActionState 的基本形态

在支持 React 19 表单 Action 的环境中,可以使用类似结构:

import { useActionState } from "react";

type ActionState = {
  message: string;
  fieldErrors?: {
    email?: string;
    password?: string;
  };
};

const initialActionState: ActionState = {
  message: "",
};

async function loginAction(
  previousState: ActionState,
  formData: FormData,
): Promise<ActionState> {
  const email = String(formData.get("email") ?? "").trim();
  const password = String(formData.get("password") ?? "");

  const fieldErrors: ActionState["fieldErrors"] = {};

  if (!email) {
    fieldErrors.email = "请输入邮箱";
  }

  if (password.length < 8) {
    fieldErrors.password = "密码至少需要 8 个字符";
  }

  if (Object.keys(fieldErrors).length > 0) {
    return {
      message: "请修正表单错误",
      fieldErrors,
    };
  }

  // 在实际项目中,这里应调用服务端能力或服务端 Action。
  return {
    message: "登录请求已提交",
  };
}

export function ActionLoginForm() {
  const [state, formAction, isPending] = useActionState(
    loginAction,
    initialActionState,
  );

  return (
    <form action={formAction}>
      <label>
        邮箱
        <input name="email" type="email" />
      </label>

      {state.fieldErrors?.email && (
        <p role="alert">{state.fieldErrors.email}</p>
      )}

      <label>
        密码
        <input name="password" type="password" />
      </label>

      {state.fieldErrors?.password && (
        <p role="alert">{state.fieldErrors.password}</p>
      )}

      {state.message && <p role="status">{state.message}</p>}

      <button type="submit" disabled={isPending}>
        {isPending ? "提交中……" : "登录"}
      </button>
    </form>
  );
}

useActionState 的 Action 接收两个参数:

(previousState, formData)

这与普通的 onSubmit(event) 不同:表单数据由 React 根据表单字段的 name 收集。没有 name 的控件不会按预期出现在 FormData 中。

2. useFormStatus 必须位于表单内部的子组件

useFormStatus 读取最近的父级 <form> 的提交状态,因此不能在同一个组件中、父级 <form> 之前直接读取它:

import { useFormStatus } from "react-dom";

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? "提交中……" : "提交"}
    </button>
  );
}

function Form() {
  return (
    <form action={someAction}>
      {/* 其他字段 */}
      <SubmitButton />
    </form>
  );
}

这里的组件关系是:

flowchart TD
  Form --> NativeForm
  NativeForm --> Input
  NativeForm --> SubmitButton
  SubmitButton --> useFormStatus
  NativeForm --> Action
  Action --> ActionState
  ActionState --> Form

useFormStatus 负责读取最近一次表单提交的 pending 状态;useActionState 负责让 Action 返回的状态进入 React 渲染。二者解决的问题不同,不能把 useFormStatus 当作任意异步函数的全局 loading 状态。

3. Action 与普通 onSubmit 的取舍

普通 onSubmit 的特点:

  • 请求过程完全显式;
  • 容易接入任意 REST 或 GraphQL API;
  • 可以精确控制取消、重试、请求序号;
  • 需要自己维护 loading、错误和结果状态。

表单 Action 的特点:

  • <form action={...}> 结合;
  • 可以自然使用 FormData
  • React 负责部分提交状态连接;
  • 服务端函数和部署框架之间存在额外约束;
  • 不能假定所有 React 环境都支持同样的服务端调用方式。

如果表单只是调用现有 HTTP API,普通 onSubmit 往往更直观。如果框架明确支持 React 19 的服务端 Action,并且希望减少客户端提交胶水代码,可以考虑 Action。


十、文件上传、富文本和复杂控件的边界

1. 文件输入通常保持非受控

浏览器不允许脚本任意设置用户选择的本地文件路径,因此不要这样做:

<input type="file" value={fileName} />

应使用 ref 或在提交时读取:

function handleSubmit(event: FormEvent<HTMLFormElement>) {
  event.preventDefault();

  const formData = new FormData(event.currentTarget);
  const file = formData.get("avatar");

  if (file instanceof File && file.size > 0) {
    // 上传文件
  }
}

服务端仍要检查:

  • 文件大小;
  • MIME 类型;
  • 文件内容,而不仅是扩展名;
  • 文件名和路径;
  • 存储位置;
  • 病毒扫描和访问权限。

2. select 和多选值

单选:

<select
  name="role"
  value={role}
  onChange={(event) => setRole(event.target.value)}
>
  <option value="user">用户</option>
  <option value="admin">管理员</option>
</select>

多选不能简单使用单字符串状态,应明确建模为数组:

const [roles, setRoles] = useState<string[]>([]);

<select
  multiple
  value={roles}
  onChange={(event) => {
    const nextRoles = Array.from(
      event.target.selectedOptions,
      (option) => option.value,
    );

    setRoles(nextRoles);
  }}
>
  <option value="reader">阅读</option>
  <option value="writer">写作</option>
</select>

3. FormData 的值不是都为字符串

const value = formData.get("remember");

其返回类型是 FormDataEntryValue | null,也就是 string | File | null。复选框被选中时通常是其 value 属性,未选中时可能没有该键,不能直接写:

const remember = Boolean(formData.get("remember"));

因为字符串 "false""on" 都会被转换为 true。应明确判断:

const remember = formData.get("remember") === "on";

如果指定了:

<input type="checkbox" name="remember" value="yes" />

则判断:

const remember = formData.get("remember") === "yes";

十一、性能:受控输入为什么可能变慢

受控输入的每次击键都会产生一次状态更新:

keydown
→ input/change
→ setState 或 dispatch
→ 组件重新渲染
→ input value 更新

这不是错误,而是受控模型的必然成本。性能问题通常发生在状态所在组件同时负责大量工作,例如:

function LargePage() {
  const [query, setQuery] = useState("");

  // 每次输入都重新计算大型列表、图表、编辑器和多个面板
  const result = expensiveSearch(query);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <HugeTable rows={result} />
      <Chart />
      <Editor />
    </>
  );
}

优化应从因果关系出发,而不是先把所有代码包进 memo

1. 缩小状态作用域

将输入框拆到较小组件中:

function SearchBox() {
  const [query, setQuery] = useState("");

  return (
    <input
      value={query}
      onChange={(event) => setQuery(event.target.value)}
    />
  );
}

如果父组件不需要每次读取 query,就不要把 query 放在父组件中。

2. 使用 memo 但理解其条件

const ResultList = memo(function ResultList({
  rows,
}: {
  rows: string[];
}) {
  return (
    <ul>
      {rows.map((row) => (
        <li key={row}>{row}</li>
      ))}
    </ul>
  );
});

memo 只会对 props 做浅比较。每次都创建的新对象仍然会导致子组件更新:

<Panel options={{ dense: true }} />

可以将稳定配置移到组件外,或使用 useMemo。但 useMemo 是性能优化提示,不应作为业务正确性的依赖。

3. 区分输入状态和昂贵派生结果

useDeferredValue 可以让非关键的派生内容滞后:

const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);

const results = useMemo(
  () => expensiveSearch(deferredQuery),
  [deferredQuery],
);

输入框仍然使用即时的 query

<input
  value={query}
  onChange={(event) => setQuery(event.target.value)}
/>

这表达了两个不同的时间要求:

query:必须立即响应用户输入
deferredQuery:可以稍后更新列表

不要用 startTransitionuseDeferredValue 延迟受控文本输入本身的 value。文本输入的控制值需要同步更新,否则可能出现输入延迟或光标行为异常。可以延迟结果列表、统计信息等非关键 UI。

4. 不要在每次输入时做不可取消的昂贵工作

常见问题包括:

  • 每次击键都发送远程请求;
  • 每次击键都进行大规模 JSON 转换;
  • 每次击键都重新初始化第三方编辑器;
  • 每次击键都重新计算整个表格。

应优先使用:

  • 防抖或节流;
  • 请求取消;
  • useMemo
  • 组件拆分;
  • 虚拟列表;
  • 将字段注册和 DOM 读取交给非受控方案;
  • 浏览器原生约束减少无意义逻辑。

5. 受控与非受控的性能取舍

大型动态表单中,如果每个字段都放在顶层 state,可能导致每次击键让整个表单重新渲染。此时可以:

  • 将每个字段拆成独立组件;
  • 让状态更靠近使用它的字段;
  • 使用 useReducer 统一复杂状态,但配合拆分;
  • 对不需要即时业务逻辑的字段使用非受控和 FormData
  • 使用成熟表单库,但仍要理解其受控、订阅和校验模型。

非受控不必然更快,受控也不必然很慢。真正决定成本的是更新频率、渲染范围和每次渲染执行的工作量


十二、常见失败表现与诊断路径

1. 输入框无法输入

失败代码:

<input value={email} />

这里提供了 value,却没有更新它的 onChange。React 每次渲染都会把旧值写回 DOM,因此用户看到输入被“锁住”。

诊断方法:

  1. 检查是否设置了 value
  2. 检查 onChange 是否执行;
  3. 检查 onChange 是否更新同一个状态;
  4. 检查状态是否被其他逻辑重置;
  5. 检查是否将异步请求结果错误地覆盖了当前输入。

2. 切换复选框无效

失败代码:

<input
  type="checkbox"
  value={remember}
  onChange={(event) => setRemember(event.target.value)}
/>

复选框的选中状态由 checked 表示,value 只是提交时的值。正确代码必须读取:

event.target.checked

3. response.ok 漏检导致错误提示“成功”

失败代码:

const data = await fetch("/api/login").then((response) =>
  response.json(),
);

dispatch({ type: "submit-success" });

服务端即使返回 401,代码也会把 JSON 当作成功数据。应先检查 HTTP 状态和业务字段:

const response = await fetch("/api/login");
const data = await response.json();

if (!response.ok || data.ok !== true) {
  // 错误
}

HTTP 状态和业务成功标志是两个层次,具体协议应由接口契约明确规定。

4. 错误与字段内容不一致

如果错误请求晚于新请求返回,通常是竞态问题。诊断时记录:

输入版本
请求开始时间
请求序号
响应时间
响应状态

然后确认旧响应是否仍然调用了 setState。解决方式是取消旧请求、比较请求序号,或两者同时使用。

5. 服务端渲染后出现 hydration 警告

常见原因包括:

  • 服务端初始值与客户端初始值不同;
  • 使用当前时间、随机数生成默认值;
  • 服务端认为字段为空,客户端首次渲染却使用了用户缓存值;
  • valuedefaultValue 混用。

受控表单应保证服务端和客户端首次渲染使用同一份初始数据,并将可能为空的值归一化:

value={serverValue ?? ""}

十三、生产取舍:何时选择哪种模型

选择受控组件

适合:

  • 输入内容需要即时影响其他 UI;
  • 字段之间有联动;
  • 需要显示即时或失焦校验;
  • 需要根据输入禁用、启用或切换控件;
  • 需要精确控制错误和焦点。

选择非受控组件

适合:

  • 字段很多,输入过程中不需要驱动其他 UI;
  • 主要在提交时读取数据;
  • 文件上传;
  • 与原生表单或第三方 DOM 控件集成;
  • 需要减少顶层状态更新范围。

选择普通 onSubmit + fetch

适合:

  • 已有 API;
  • 需要取消、重试、请求序号、超时等精细控制;
  • 客户端负责较多交互状态;
  • 不依赖特定框架的服务端 Action。

选择 React 19 表单 Action

适合:

  • 运行环境明确支持相关能力;
  • 表单提交天然以 FormData 为中心;
  • 希望将 Action 返回状态连接到表单;
  • 服务端函数边界由框架提供并且团队熟悉其部署模型。

无论使用哪种方案,都不能省略服务端校验、权限判断、错误分类和敏感信息保护。


十四、最终状态机

一个工程化表单可以抽象为以下状态机:

stateDiagram-v2
  [*] --> Idle

  Idle --> Editing: 用户修改字段
  Editing --> Editing: 输入/失焦
  Editing --> Invalid: 提交且客户端校验失败
  Invalid --> Editing: 修正字段

  Editing --> Submitting: 提交且校验通过
  Submitting --> Success: HTTP 成功且业务成功
  Submitting --> FieldError: 服务端返回字段错误
  Submitting --> FormError: 服务端返回表单错误
  Submitting --> NetworkError: 网络失败或超时
  Submitting --> Editing: 请求被取消

  FieldError --> Editing: 修改错误字段
  FormError --> Editing: 修改字段或重试
  NetworkError --> Submitting: 重试
  Success --> [*]

这张图中的关键约束是:

Submitting必须有结束路径Submitting \Rightarrow \text{必须有结束路径}

也就是说,提交开始后,代码必须能够进入成功、业务错误、网络错误或取消路径,不能因为异常未捕获而永久保持 submitting = true

同时:

Success⇍ClientValidationPassedSuccess \not\Leftarrow ClientValidationPassed

客户端校验通过只允许请求发出,并不意味着业务操作成功。最终成功必须由服务端响应确认。

表单工程的核心不是“把输入值放进 state”,而是建立一条可验证的数据流:

字段值
→ 明确的状态模型
→ 分层校验
→ 可取消、可诊断的异步提交
→ 分类错误
→ 可访问的反馈
→ 受控的渲染成本

当这些边界被明确后,useStateuseReducer、批处理、状态快照、表单 Action 和性能优化就不再是孤立 API,而会成为同一个表单状态系统中的不同工具。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。