讲解

React 表单有两种模式。受控组件(主流):输入框的值由 state 驱动——<input value={name} onChange={e => setName(e.target.value)}>,每次输入都更新 state,输入框显示永远等于 state。好处是数据尽在掌握:实时校验、禁用提交按钮、格式化输入(比如自动大写)都是一行判断的事。代价是每个控件一个 state,大表单会有不少样板代码。

非受控组件:值由 DOM 自己管,提交时用 ref 读出来。适合「只在提交时关心值」的简单场景(搜索框回车搜索),或集成第三方 DOM 库。文件上传必须用非受控(<input type="file"> 的 value 不能由 JS 设置),通过 ref 拿 e.target.files。

受控组件的几个细节。checkbox 用 checked 而不是 value;radio 组每个选项 checked={value === 'x'};select 把 value 放在 <select> 上而不是 option 的 selected。提交用 form 的 onSubmit + e.preventDefault() 阻止整页刷新。校验分两层:即时反馈(输入时提示格式)和提交校验(兜底),提交按钮的 disabled 由「表单是否有效」推导——注意这个「是否有效」是典型的可推导数据,不该单独存 state。

示例

实现一个受控表单的状态机:值、校验、提交按钮可用性全部从 state 推导(在本教程构建时被真实执行):

import assert from 'node:assert/strict';

// 受控表单的核心:值存 state,校验是推导
function createForm() {
  const state = { name: '', email: '', agreed: false };
  return {
    state,
    setField(key, value) {
      state[key] = value; // 真实组件里:setForm({ ...form, [key]: value })
    },
    // 校验规则:纯函数,从 state 推导
    errors() {
      const errs = {};
      if (state.name.trim().length < 2) errs.name = '姓名至少 2 个字';
      if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(state.email)) errs.email = '邮箱格式不对';
      if (!state.agreed) errs.agreed = '请勾选协议';
      return errs;
    },
    canSubmit() {
      return Object.keys(this.errors()).length === 0; // 可推导,不单独存
    },
  };
}

const form = createForm();
assert.strictEqual(form.canSubmit(), false); // 空表单不可提交
assert.deepStrictEqual(Object.keys(form.errors()), ['name', 'email', 'agreed']);

form.setField('name', '小明');
form.setField('email', 'not-an-email');
assert.strictEqual(form.errors().email, '邮箱格式不对');

form.setField('email', 'ming@example.com');
form.setField('agreed', true);
assert.strictEqual(form.canSubmit(), true); // 全部合法 → 可提交

console.log('填写后校验:', JSON.stringify(form.errors()));
console.log('canSubmit 由 errors 推导:', form.canSubmit());

真实组件:

import { useState } from 'react';

function SignupForm() {
  const [form, setForm] = useState({ name: '', email: '', agreed: false });
  const set = (key) => (e) =>
    setForm({ ...form, [key]: e.target.type === 'checkbox' ? e.target.checked : e.target.value });

  const errors = {};
  if (form.name.trim().length < 2) errors.name = '姓名至少 2 个字';
  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email)) errors.email = '邮箱格式不对';

  function handleSubmit(e) {
    e.preventDefault();
    if (Object.keys(errors).length === 0 && form.agreed) {
      console.log('提交', form);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={form.name} onChange={set('name')} placeholder="姓名" />
      {form.name && errors.name && <em>{errors.name}</em>}
      <input value={form.email} onChange={set('email')} placeholder="邮箱" />
      <label>
        <input type="checkbox" checked={form.agreed} onChange={set('agreed')} />
        同意协议
      </label>
      <button disabled={Object.keys(errors).length > 0 || !form.agreed}>注册</button>
    </form>
  );
}

常见坑

  • input 只给 value 不给 onChange:变成只读输入框,React 会警告;要么补 onChange,要么用 readOnly/defaultValue。
  • checkbox 用 value 绑定:受控 checkbox 绑 checked,值在 checked 里。
  • 每个控件手写一套 handler:用计算属性名 set(key) 工厂收敛,或上表单库(react-hook-form)。
  • 校验结果存 state:校验是 state 的推导,存 state 会导致「值改了错误没更新」的时序 bug。
  • 大表单全受控导致卡顿:几百个控件每次都重渲染,考虑按字段拆分组件或非受控 + react-hook-form。

小结

受控 = value + onChange 驱动 state;校验与可提交性都从 state 推导;文件上传用非受控。下一章讲处理外部世界的钩子:useEffect。