React 表单并不难:受控、非受控和我踩过的那些坑

77 阅读5分钟

前言

在学习 React 表单时,很多人都会被一个问题困住:
输入框里的值,到底该不该交给 state 管?

一开始看文档时,受控组件和非受控组件像是两个对立的概念,似乎选错了就会写出“错误的 React 代码”。但随着写的表单越来越多,会发现它们更像是 React 提供的两种使用方式,而不是非此即彼的规范。

这篇文章并不是想给出一个“标准答案”,而是结合实际写代码的体验,梳理在什么情况下选择受控更合适,什么时候又该让输入保持非受控。理解这一点之后,再回头看表单相关的代码,思路会清晰很多。

二、受控组件:为什么要“受控”?

受控组件的核心是把表单值放在 React 的 state 里,用户输入通过 onChange 更新 state,视图由 state 驱动,数据和界面保持一致。

适合场景:需要即时校验、联动其他字段、或在页面多处共享当前输入值的情况。表单重置、默认值和提交前的数据处理也更直观。

import { useState } from "react";

export default function Control() {
  // 输入值由 React state 统一管理
  const [message, setMessage] = useState("123");

  // 输入变化时,更新 state
  function changeHandler(e) {
    setMessage(e.target.value);
  }

  return (
    <div>
     {/* value 来自 state,必须配合 onChange */}
      <input
        type="text"
        value={message}
        onChange={changeHandler}
      />
    </div>
  );
}

需要注意的是:给输入绑定 value 时务必同时提供 onChange,若不绑定 onChange,输入框会变为只读(React 会警告),这也是我踩过的坑😭。我会在下面细说一下。

为什么忘了 onChange,输入框就“只读”了?

很多像我一样的初学者会碰到这样的问题:
为什么明明能点击输入框、光标也在闪烁,但输入的内容却一闪即逝,最终总被重置回初始值❓

这并不是 React 的 bug,而是数据单向流动的一种“保护机制”

当你绑定了 value={state} 却忘了写 onChange,就等于告诉 React:
“这个输入框的值完全由我控制,只允许显示 state 里的内容。”

而 React 也确实这么做了——在每次组件重新渲染时,它都会拿当前的 state 值“覆盖”输入框的实际内容

让我们看看发生了什么:

const [text, setText] = useState("123");

// ❌ 只有 value,没有 onChange
<input value={text} />

// 用户输入"abc"的瞬间过程:
// 1. 用户敲下"a" → 输入框短暂显示"123a"(浏览器原生行为)
// 2. 任何触发重新渲染的操作(如其他 state 更新)→ React 重新执行组件函数
// 3. text 还是"123" → React 将输入框的 value 强制设回"123"
// 4. 用户看到的"a"字被瞬间擦除

因此,只要你选择了受控模式,就必须同时提供 onChange
让 React 在接管输入的同时,也能接收到“值发生了变化”的通知。

三、非受控组件:什么时候“原地取值”更合适?

非受控组件不把值交给 React 管理,而是保留在 DOM 中,需要时用 ref 读取。它更接近原生 JavaScript 的使用方式,适合表单很简单、性能敏感,或需要接入非 React 第三方控件的场景。

文件上传尤其适合非受控:浏览器有现成的 .files API,用 ref 读取比强行受控更自然。

import { useRef } from "react";

export default function UnControl() {
 // 通过 ref 直接获取 DOM
  const inputRef = useRef(null);

  function login() {
    // 在需要时读取输入框当前值
    console.log(inputRef.current.value);
  }

  return (
    <div>
      {/* 不使用 value,由 DOM 自己维护 */}
      <input type="text" ref={inputRef} />
      <button onClick={login}>登录</button>
    </div>
  );
}

四、两者对比:从“可维护性”与“体验”来定夺

受控和非受控的本质区别在于“谁来管理输入值”。如果输入需要频繁参与业务逻辑(实时校验、联动、跨组件共享),交给 state 更清晰;如果只是流程中的中间结果、或只在提交时需要读取,直接从 DOM 取值更简单。

所以在真实项目里,通常并不是全受控或全非受控,而是按需混合:
重要、需要被业务反复使用的值用受控;
只在关键节点读取一次的内容用非受控。

这样既保持代码清晰,又避免不必要的复杂性。

下面就是一个受控和非受控组件混合使用的例子:

五、简短的进阶示例:混合场景

用户名是典型的业务字段,需要校验、展示或参与后续逻辑,因此使用受控组件;
而文件上传更偏向浏览器的原生能力,只在提交时读取一次,用非受控反而更自然。

import { useState, useRef } from "react";

function HybridForm() {
  // 受控:用户名放在 state,便于校验或在 UI 其他地方使用
  const [username, setUsername] = useState("");
  // 非受控:文件用 ref,文件对象不适合放 state
  const fileRef = useRef(null);

  function handleSubmit(e) {
    e.preventDefault(); // 阻止默认提交(以 JS 处理提交逻辑)
    // 直接从 DOM 读取文件(假定 input 已挂载)
    const file = fileRef.current.files[0];
    console.log("用户名:", username, "上传文件:", file ? file.name : "无");
  }

  return (
    <form onSubmit={handleSubmit}>
      {/* 受控输入:value 来自 state,onChange 更新 state */}
      <input
        value={username}
        onChange={e => setUsername(e.target.value)}
        placeholder="用户名"
      />
      {/* 非受控文件输入:通过 ref 在提交时读取 */}
      <input type="file" ref={fileRef} />
      <button type="submit">提交</button>
    </form>
  );
}


七、结语

受控与非受控并不是“孰对孰错”的二选一,而是工具箱里的两把不同尺寸的螺丝刀:
需要被 React 频繁使用和管理的值,用受控会更安心;
只在某个时刻读取一次的内容,用非受控反而更轻松。
真实项目里多用混合,按需取用即可。多写、多试、少纠结,你会慢慢找到自己的规则。