《受控 vs 非受控:你以为用对了 useState,直到你写了那个表单》

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 只是偶尔去读一下。


那张图,一眼看穿区别

sequenceDiagram participant User as 用户 participant DOM as DOM (input) participant React as React State User->>DOM: 输入 &#34;a&#34; DOM->>React: onChange → setValue(&#34;a&#34;) React->>DOM: re-render → value=&#34;a&#34; User->>DOM: 输入 &#34;b&#34; DOM->>React: onChange → setValue(&#34;ab&#34;) React->>DOM: re-render → value=&#34;ab&#34; Note over React,DOM: 受控:每一次按键都是 React → DOM 的往返
sequenceDiagram participant User as 用户 participant DOM as DOM (input) participant React as React Ref User->>DOM: 输入 &#34;a&#34; User->>DOM: 输入 &#34;b&#34; User->>DOM: 输入 &#34;c&#34; User->>DOM: 点「提交」 DOM->>React: ref.current.value → &#34;abc&#34; Note over React,DOM: 非受控:React 不管你在 DOM 里打什么,直到你自己去读

受控是一次按键一次往返,非受控是「我不理你,直到我需要」。


真实场景:一个注册表单,什么时候出问题?

回到我们的注册表单:

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 的全部问题,一次性看透

回看我们登录表单的完整流程:

graph TD A[&#34;用户输入 username&#34;] --> B[&#34;handleChange&#34;] B --> C[&#34;setForm({...form})&#34;] B --> D[&#34;validate(name, value)&#34;] D --> E[&#34;setErrors(prev => ({...prev}))&#34;] C --> F[&#34;form 更新&#34;] E --> G[&#34;errors 更新&#34;] F --> H[&#34;isValid 重新计算&#34;] G --> H H --> I[&#34;isValid 判断&#34;] I -->|true| J[&#34;按钮可点击&#34;] I -->|false| K[&#34;按钮 disabled&#34;]

关键点:

  1. validate 在读 value 参数,不是在读 form state ------所以即使 setForm 用了有风险的直接展开,validate 不受影响
  2. isValid 依赖 form 和 errors 两个 state------如果 form 的更新被覆盖或延迟,isValid 的判断就错了
  3. 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 了但读到的值不对」的经历吗?评论区聊聊。


相关推荐
嘟嘟07171 小时前
React 受控组件与非受控组件:表单数据到底归谁管?
前端·javascript·react.js
嘟嘟07171 小时前
React 表单管理进阶:从单字段到带校验的完整登录表单
前端·javascript·react.js
光影少年1 小时前
react navite原生事件监听、全局事件通知
前端·react native·react.js
烬羽1 小时前
《memo 明明加了,为什么子组件还是渲染了?》
react.js·性能优化·全栈
做前端的娜娜子1 小时前
什么是浅拷贝(Shallow Copy)和深拷贝(Deep Copy)?如何手动实现一个深拷贝函数
javascript·面试·掘金·金石计划
xcLeigh1 小时前
编程语言的 AI 友好度排名:Python、JavaScript、TypeScript 谁更适合 AI 辅助
javascript·人工智能·python·ai·typescript·ai编程
码上成长2 小时前
微前端 Invalid hook call?先把「window 注入 + externals」整明白
前端·react.js·前端框架
宿6742 小时前
vue3-DOM树
前端·javascript·vue.js
谷哥的小弟2 小时前
TypeScript中的any
javascript·typescript