陌上人如玉
公子世无双

1.8 表单处理

1.8 表单处理

一句话:React 里处理表单有两条路——受控表单(state 驱动,适合实时校验与联动)与非受控 / FormData(提交时收集,代码更少);React 19 的 form action 进一步把「提交 + loading + 错误」统一成一套模式。

一、单字段受控表单

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();                 // ⭐ 阻止浏览器默认的表单提交(会刷新页面)
    console.log({ email, password });
  };

  return (
    
setEmail(e.target.value)} /> setPassword(e.target.value)} />
); }

二、多字段状态管理

// ① 多个独立的 useState(字段少时最清晰)
const [name, setName] = useState('');
const [age, setAge] = useState(0);
const [agree, setAgree] = useState(false);

// ② 一个对象(字段多时更简洁,但更新要展开)
const [form, setForm] = useState({ name: '', age: 0, agree: false });

const handleChange = (e: React.ChangeEvent) => {
  const { name, value, type, checked } = e.target;
  setForm((prev) => ({
    ...prev,
    [name]: type === 'checkbox' ? checked : value,   // ⭐ 区分 checkbox
  }));
};

// 使用:name 必须与 state 的键一致


// ③ 用 useReducer(字段多、逻辑复杂时推荐)
type FormState = { name: string; age: number; agree: boolean };
type FormAction =
  | { type: 'change'; field: keyof FormState; value: string | number | boolean }
  | { type: 'reset' };

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'change':
      return { ...state, [action.field]: action.value };
    case 'reset':
      return { name: '', age: 0, agree: false };
    default:
      return state;
  }
}

const [form, dispatch] = useReducer(formReducer, { name: '', age: 0, agree: false });

 dispatch({ type: 'change', field: 'name', value: e.target.value })}
/>;
方案 适用 更新复杂度
多个 useState 2~3 个字段 简单
单个对象 state 4~10 个字段 需要展开
useReducer 字段多 / 有重置与复杂逻辑 集中管理
非受控 + FormData 只需提交时的值 最简单

三、通用 change 处理

// 一个通用的「按 name 更新」处理函数(覆盖 input / select / textarea / checkbox)
function useFormState>(initial: T) {
  const [values, setValues] = useState(initial);

  const handleChange = useCallback(
    (e: React.ChangeEvent) => {
      const target = e.target;
      const { name, type } = target;
      let value: unknown = target.value;

      if (type === 'checkbox') value = (target as HTMLInputElement).checked;
      else if (type === 'number' || type === 'range') value = Number(target.value);
      else if ((target as HTMLSelectElement).multiple) {
        value = Array.from((target as unknown as HTMLSelectElement).selectedOptions, (o) => o.value);
      }

      setValues((prev) => ({ ...prev, [name]: value }));
    },
    []
  );

  const reset = useCallback(() => setValues(initial), [initial]);

  return { values, handleChange, reset, setValues };
}

四、表单校验

// ① 实时校验(受控表单的优势)
function Form() {
  const [email, setEmail] = useState('');
  const [touched, setTouched] = useState(false);

  const error = useMemo(() => {
    if (!email) return '请输入邮箱';
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return '邮箱格式不正确';
    return '';
  }, [email]);

  return (
    
setEmail(e.target.value)} onBlur={() => setTouched(true)} // ⭐ 失焦后才显示错误(体验更好) aria-invalid={!!error && touched} aria-describedby="email-error" /> {touched && error && }
); } // ② 提交时校验 const handleSubmit = (e) => { e.preventDefault(); const errors = validate(form); if (Object.keys(errors).length) { setErrors(errors); return; } submit(form); }; // ③ 用第三方校验库(zod / yup) import { z } from 'zod'; const schema = z.object({ email: z.string().email('邮箱格式不正确'), age: z.number().min(18, '年龄不能小于 18'), }); const result = schema.safeParse(form); if (!result.success) { setErrors(result.error.flatten().fieldErrors); }

校验的两个原则:

① 前端校验只是为了【体验】(即时反馈),服务端必须重新校验
② 错误提示要「及时但不打扰」—— 常见做法是「失焦时首次校验,之后实时校验」

五、非受控 + FormData

function SimpleForm({ onSubmit }: { onSubmit: (data: Record) => void }) {
  const formRef = useRef(null);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const fd = new FormData(e.currentTarget);          // ⭐ 直接从 form 元素收集
    onSubmit(Object.fromEntries(fd) as Record);
  };

  return (
    
{/* ⭐ 必须有 name */} {/* file 只能用这种方式 */}
); }
维度 受控 + useState 非受控 + FormData
代码量 多 少
实时校验/联动 ✅ ❌(需监听原生事件)
重渲染 每次输入都渲染 不渲染
文件上传 ❌ ✅
适合 字段少、需实时反馈 字段多、只取提交值

六、React 19 的 Actions

// React 19:用 action 属性把「提交 + loading + 错误」统一起来
import { useActionState } from 'react';

function SignupForm() {
  const [state, formAction, isPending] = useActionState(
    async (prevState, formData: FormData) => {
      // 这个函数可以是 async,也可以在服务端运行(Server Action)
      const res = await fetch('/api/signup', { method: 'POST', body: formData });
      if (!res.ok) return { error: '注册失败,请重试' };
      return { success: true };
    },
    { error: '', success: false }
  );

  return (
    
{state.error &&

{state.error}

}
); }
// 配套的 Hooks(React 19)
import { useFormStatus, useOptimistic } from 'react';

// useFormStatus:读取最近的 form 提交状态(必须在 form 的子组件里用)
function SubmitButton() {
  const { pending, data, method } = useFormStatus();
  return ;
}

// useOptimistic:乐观更新
function Comments({ comments, addComment }) {
  const [optimistic, addOptimistic] = useOptimistic(
    comments,
    (current, newComment: string) => [...current, { id: 'temp', text: newComment, pending: true }]
  );

  async function submit(formData: FormData) {
    addOptimistic(formData.get('text') as string);        // 立即显示
    await addComment(formData.get('text') as string);      // 后台真正提交
  }

  return (
    
{optimistic.map((c) => )} ); }
概念 作用
<form action={fn}> 提交时自动调用 fn(formData),并自动处理 preventDefault 与 reset
useActionState 管理 action 的返回值状态与 pending 状态
useFormStatus 在子组件里读取所属 form 的提交状态
useOptimistic 乐观更新(先展示结果,失败再回滚)
Server Actions "use server" 标记的函数,表单可直接调用服务端逻辑

七、第三方方案

// react-hook-form:内部用【非受控 + ref】,性能好、校验内置
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

function Form() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm({
    resolver: zodResolver(schema),
    defaultValues: { username: '', age: 18 },
  });

  return (
    
console.log(data))}> {errors.username && {errors.username.message}}
); }
方案 特点 适合
受控 + useState 直观、可控 小表单
非受控 + FormData 代码最少 简单提交
react-hook-form 非受控 + 订阅,性能好、生态好 中型/大型表单
Formik 受控方案,较重 老项目
React 19 Actions 原生集成、与 Server Actions 配合 新项目、Next.js

八、常见坑

// ① 忘记 preventDefault → 页面刷新
// ⚠️ 需要在处理函数里 e.preventDefault() // ② 受控 input 忘了 onChange // ⚠️ 只读 + 警告 // ③ 忘记 name 导致 FormData 收集不到 // ❌ FormData 里没有这个字段 // ④ checkbox 用 value 而不是 checked // ❌ // ⑤ select 的默认值写在 option 上 // ❌ React 用 select 的 value // ⑥ 数字字段拿到的是字符串 const age = formData.get('age'); // string 类型 // ✅ Number(age) 或 register('age', { valueAsNumber: true }) // ⑦ 在 onChange 里做昂贵计算 → 输入卡顿 onChange={(e) => { setV(e.target.value); search(e.target.value); }} // ⚠️ 每次按键都请求 // ✅ 防抖 / useDeferredValue // ⑧ 提交时没有禁用按钮 → 重复提交 // ⚠️ 用户连点会发多次请求 // ✅ // ⑨ 错误提示没有关联到输入框(无障碍问题) {error} // ⚠️ 读屏用户不知道是哪个字段 // ✅ // ⑩ 表单重置时受控字段没重置 const handleReset = () => { setForm(initial); } // ✅ 需要手动重置所有 state // 或使用 的 reset + key 强制重建(不推荐,会丢焦点)

面试延伸

  1. 「React 里的表单怎么做?」

两条路:① 受控表单——每个字段的值存在 state 里(value + onChange),React 是唯一数据源,能做实时校验、格式化、联动,代价是每次输入都重渲染;② 非受控——用 defaultValue 设初值,提交时通过 ref 或 new FormData(e.currentTarget) 收集,代码更少、不重渲染,但无法实时响应。字段少用受控、字段多用非受控或用 react-hook-form。

  1. 「为什么受控组件必须写 onChange?」

因为 React 认为你「想让 React 控制这个值」(提供了 value 属性),但没提供「值怎么变」的路径——于是用户的输入会被 value 立即覆盖回去,输入框变成只读,React 会发出警告(You provided a value prop to a form field without an onChange handler)。如果确实要只读,要显式写 readOnly。

  1. 「多字段表单怎么管理状态?」

按字段数量选择:2~3 个用多个 useState(最清晰);4~10 个用一个对象 state + 统一的 handleChange(按 e.target.name 更新);字段很多或逻辑复杂用 useReducer(把「状态 + 变更规则」集中管理,还方便做重置);超大表单直接用 react-hook-form(非受控 + 订阅,避免大量重渲染)。

  1. 「checkbox 和 select 的受控写法有什么特别之处?」

两处差异:① checkbox 用 checked 而不是 value,事件里读 e.target.checked(布尔值),而 value 在复选框上表示「提交时的值」;② select 的 value 要写在 <select> 上(而不是给某个 <option> 加 selected),多选时传数组。此外 radio 用 checked 并配合相同的 name 分组。

  1. 「表单校验应该怎么做?」

分三层:① 前端即时校验(受控表单的优势)——但要注意「失焦时才首次显示错误」体验更好(否则用户刚输入一半就报红);② 提交时统一校验(无论受控还是非受控都能做);③ 服务端校验(必须)——前端校验只是体验优化,绕过它太容易。复杂规则建议用 zod / yup 声明式定义 schema,同时获得运行时的校验与 TS 类型推导。

  1. 「怎么防止表单重复提交?」

① 禁用提交按钮(disabled={isSubmitting})——最常用;② 提交中显示 loading;③ 在提交函数里加锁(用 ref 记录是否正在提交,避免异步竞态);④ 服务端做幂等(用幂等键 / 唯一约束),因为即使前端防住了,用户也可能刷新重试或直接调接口。React 19 的 useFormStatus 能方便地拿到 pending 状态。

  1. 「React 19 的 Actions 解决了什么问题?」

把表单提交的三条重复逻辑统一了:① 手动 preventDefault + 收集数据(现在写成 <form action={fn}>,React 会把 FormData 传进去并自动处理这两步);② 手动管理 loading(现在用 useActionState 返回的 isPending 或子组件里的 useFormStatus);③ 手动管理错误与结果状态(现在 useActionState 统一管)。配合 Server Actions 还能直接在表单里调用服务端函数,少写一层接口。

  1. 「受控表单的性能问题怎么解决?」

三个方向:① 拆分子组件——每个字段自己管理 state,输入只重渲染该字段而不是整张表单;② 改用非受控(ref / FormData)——完全不触发重渲染;③ 延迟昂贵计算——如果输入要驱动搜索或过滤,用防抖或 useDeferredValue 把重计算降优先级。react-hook-form 之所以快,就是因为它用「非受控 + 订阅」避免了每次输入都重渲染整张表单。

一句话速记

表单两条路:受控(value + onChange,能实时校验/联动,但每次输入都重渲染)与非受控(defaultValue + ref/FormData,代码少不重渲染);多字段用「单对象 state + 按 name 统一处理」或 useReducer,大表单用 react-hook-form;checkbox 用 checked、select 的 value 写在 select 上、file 只能非受控;提交记得 preventDefault + 禁用按钮防重复提交;React 19 用 <form action> + useActionState + useFormStatus 统一处理提交状态。

赞(0) 打赏
未经允许不得转载:陌上寒 » 1.8 表单处理

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫