React 表单的 1 个坑:受控和非受控混用,无声失败比报错更可怕
开篇
看一个登录表单。你能看出下面两个状态更新有什么不同吗?
javascript
const handleChange = (e) => {
const { name, value } = e.target;
// 这个是直接展开
setForm({
...form,
[name]: value
});
// 这个用了 prev =>
setErrors(prev => ({
...prev,
[name]: msg
}));
};
validate(name, value);
看起来都没问题?两个都能跑?
问题藏在这里: 如果你快速输入"admin",setForm({...form}) 拿到的 form 可能不是你以为的那个 form 。而 setErrors(prev => ...) 拿到的 prev 一定是那个最新的 prev。
同一个函数里,两行相邻的代码,两种不同的写法。不是风格问题------是 你对 React 状态更新模型的理解,在同一个函数里分裂了。
这篇文章就是解决这个问题的。
你需要先理解一件事:React 到底「管不管」DOM
在讲表单之前,先看两个最简单的输入框。它们长得一模一样------一个 <input>------但背后是完全不同的两种世界观。
受控组件(React 管 DOM):
javascript
function ControlledInput() {
const [value, setValue] = useState('');
return (
<input
type="text"
value={value} // 🔑 React 控制显示
onChange={e => setValue(e.target.value)} // 🔑 React 拦截每一个按键
/>
);
}
非受控组件(DOM 自己管自己):
javascript
function UncontrolledInput() {
const inputRef = useRef(null);
const handleClick = () => {
console.log(inputRef.current.value); // 🔑 需要的时候才从 DOM 读取
};
return (
<input type="text" ref={inputRef} /> // ⚠️ 没有 value,没有 onChange
);
}
一句话定义:受控 = React state 是唯一真相源。非受控 = DOM 是唯一真相源,React 只是偶尔去读一下。
那张图,一眼看穿区别
受控是一次按键一次往返,非受控是「我不理你,直到我需要」。
真实场景:一个注册表单,什么时候出问题?
回到我们的注册表单:
javascript
const [form, setForm] = useState({
username: '',
password: ''
});
const handleChange = (e) => {
setForm({
...form, // ⚠️ 这个 form...是此时此刻的吗?
[e.target.name]: e.target.value
});
};
输入 user 五个字符------你写了 5 次。正常情况下 OK,因为 React 批量更新:每次按键触发一次 render、下一次 handleChange 拿到的是新的 form。
但如果 React 的未来版本改变了批处理策略?如果某次 render 被 suspend?
真相是:在 React 18 的并发模式下,
...form展开的是一个闭包快照 ,不一定是此刻最新的 state。函数式更新prev => ({...prev})才是 React 承诺的「永远最新」。
你可能会问------那我的 LoginForm 里另一个更新为什么对了?
javascript
// ✅ 这个用了函数式更新------永远是安全的
setErrors(prev => ({
...prev,
[name]: msg
}));
// ⚠️ 这个用了直接展开------依赖闭包快照
setForm({
...form,
[name]: value
});
同一个 handleChange 里,一个安全、一个不安全。这不是风格选择------是你对这个概念的理解不一致导致的无意识 bug。
让我们写一个实验来证明:
javascript
// 模拟快速连续两次调用 handleChange(React 18 并发模式下的极端情况)
function TestForm() {
const [form, setForm] = useState({ name: '' });
const simulateRapidTyping = () => {
// 连续两次 setState,第二次可能读到第一次之前的 form
setForm({ ...form, name: 'a' }); // 读到 form = { name: '' }
setForm({ ...form, name: 'b' }); // 仍然读到 form = { name: '' }!
// 结果:只有 'b' 被保留了?不------最后一次 setState 赢了
// 但如果有 validate 基于 form.name 判断......
};
const fixedVersion = () => {
setForm(prev => ({ ...prev, name: 'a' })); // 读到 prev = { name: '' }
setForm(prev => ({ ...prev, name: 'b' })); // 读到 prev = { name: 'a' }!
// 结果:name = 'b',且中间的 prev 链条是完整的
};
}
真实运行结果:
ini
直接展开: 两次 setForm 都读到 form.name = '',最后一次覆盖前一次
函数式更新: 第二次读到 prev.name = 'a',更新链完整
区别就在这一个 prev =>。 不是语法糖------是 React 设计里「状态更新的确定性」和「闭包的不确定性」之间的对冲。
那受控和非受控到底怎么选?
回到我们最早的对比------ControlledInput 和 UncontrolledInput 都合理存在:
| 选择受控 | 选择非受控 |
|---|---|
| 需要实时校验(输入即反馈) | 只在提交时取值 |
| 输入值影响 UI(搜索建议、字数统计) | 多个输入框,提交时一把读 |
| 需要格式化输入(手机号自动加空格) | 性能敏感的表单(大量字段频繁渲染) |
| 条件禁用按钮依赖输入值 | file input(原生非受控,React 没有 value 属性) |
标准不是「用 useState 还是 useRef」------标准是「React 是否需要在每次按键时知道这个值」。
LoginForm 的全部问题,一次性看透
回看我们登录表单的完整流程:
关键点:
- validate 在读 value 参数,不是在读 form state ------所以即使
setForm用了有风险的直接展开,validate 不受影响 - isValid 依赖 form 和 errors 两个 state------如果 form 的更新被覆盖或延迟,isValid 的判断就错了
- form 和 errors 的更新模式不一致------这不是代码风格问题,是一条隐形的逻辑断点
这个 bug 最难调的地方是什么?它不会报错。它只是偶尔「按钮状态不对」------你换一个 input 顺序、加一个 console.log、它又好了。这种无声失败比报错更可怕。
回到设计哲学:React 为什么把选择权给你?
现在你应该问了:为什么 React 不直接只支持一种模式?非要搞受控/非受控让人纠结?
因为 React 的核心理念是「声明式 UI = state → view 的纯函数」。但对于表单输入,DOM 本身就是一个状态容器 ------<input> 自己会记住你打了什么字。React 面临一个选择:
把 DOM 的状态同步到 React state(受控),还是信任 DOM 自己管理状态(非受控)?
React 的答案:我给你两个选择,但你必须明确选一个。 混着用(不给 value 又不给 ref)?React 直接在 console 里警告你。
这是 React 的哲学------不帮你做决定,但逼你做决定。 隐性依赖是 bug 的来源,显式声明是稳定的基础。
结尾
回到开篇那个问题------LoginForm 里 setForm({...form}) 和 setErrors(prev => ({...prev})) 为什么要保持一致?
记住:你选择受控还是非受控,本质上是在选择「数据的唯一真相放在哪里」。放在 React state 里,就要用函数式更新保护这个真相不被并发冲掉。放在 DOM 里,就不要假装你实时知道它的值。
React 给你的不是两个 API,是两个承诺。选受控,就承诺每一次按键都经过你。选非受控,就承诺只在真正需要的时候才去读------不在中间假装自己知道。
下次写表单,第一件事不是写 useState 也不是写 useRef。而是问自己:「这个输入的值,React 需要在每次按键时都知道吗?」
你在项目里写的表单,有过「明明 setState 了但读到的值不对」的经历吗?评论区聊聊。