React 受控组件与非受控组件

深入理解受控组件与非受控组件的概念,什么时候用哪种模式,受控/非受控混用的场景,以及为什么 React 推荐受控组件。

React 受控组件与非受控组件

从表单说起

表单是 Web 开发中最常见的交互元素之一。当你在 React 中处理表单时,你会遇到一个根本性的问题:表单的值应该由 React 管理,还是由 DOM 自己管理?

这是一个看似简单但实际有深度的问题。它涉及 React 的数据流理念、组件状态设计、以及性能和工程实践的权衡。


受控组件的定义

受控组件是指:表单的值由 React state 完全控制的组件。

对于受控组件,每个表单元素都有一个对应的 state 和 onChange 处理函数:

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

  function handleEmailChange(e) {
    setEmail(e.target.value);
  }

  function handlePasswordChange(e) {
    setPassword(e.target.value);
  }

  function handleSubmit(e) {
    e.preventDefault();
    console.log({ email, password });
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Email</label>
        <input
          type="email"
          value={email}
          onChange={handleEmailChange}
        />
      </div>
      <div>
        <label>Password</label>
        <input
          type="password"
          value={password}
          onChange={handlePasswordChange}
        />
      </div>
      <button type="submit">Login</button>
    </form>
  );
}

在这个例子里:

  • emailpassword 都是 React state
  • 输入框的 value 属性绑定到 React state
  • 输入框的 onChange 事件更新 React state
  • 表单的值完全由 React 控制

非受控组件的定义

非受控组件是指:表单的值由 DOM 自己维护,用 ref 来获取值的组件。

function LoginForm() {
  const emailRef = useRef(null);
  const passwordRef = useRef(null);

  function handleSubmit(e) {
    e.preventDefault();
    console.log({
      email: emailRef.current.value,
      password: passwordRef.current.value
    });
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Email</label>
        <input type="email" ref={emailRef} />
      </div>
      <div>
        <label>Password</label>
        <input type="password" ref={passwordRef} />
      </div>
      <button type="submit">Login</button>
    </form>
  );
}

在这个例子里:

  • 没有 state 来存储 email 和 password
  • ref 获取 DOM 元素的引用
  • 在提交时才通过 ref.current.value 读取值
  • 表单的值由 DOM 自己管理

使用 defaultValue 初始化

非受控组件可以用 defaultValue 来设置初始值:

function LoginForm() {
  const [defaultEmail] = useState('[email protected]');

  return (
    <form>
      <input
        type="email"
        ref={emailRef}
        defaultValue={defaultEmail}
      />
      <input
        type="password"
        ref={passwordRef}
        defaultValue="secret"
      />
    </form>
  );
}

注意:只能用 defaultValue 设置初始值,之后值的控制权就在 DOM 手里了。如果你在非受控组件上使用 value 属性,会导致输入框无法编辑(除非你也绑定了 onChange)。


什么时候用受控组件

受控组件适合这些场景:

场景一:需要实时验证

function EmailInput() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');

  function handleChange(e) {
    const value = e.target.value;
    setEmail(value);

    if (!value.includes('@')) {
      setError('请输入有效的邮箱地址');
    } else {
      setError('');
    }
  }

  return (
    <div>
      <input value={email} onChange={handleChange} />
      {error && <span style={{ color: 'red' }}>{error}</span>}
    </div>
  );
}

实时验证需要每次输入都触发验证逻辑,这必须是受控的。

场景二:需要联动其他字段

function BillingForm() {
  const [country, setCountry] = useState('US');
  const [state, setState] = useState('');
  const [zipCode, setZipCode] = useState('');

  const stateOptions = getStateOptions(country);
  const zipPattern = getZipPattern(country);

  return (
    <div>
      <select value={country} onChange={e => setCountry(e.target.value)}>
        <option value="US">United States</option>
        <option value="CA">Canada</option>
      </select>

      <select value={state} onChange={e => setState(e.target.value)}>
        {stateOptions.map(opt => (
          <option key={opt.value} value={opt.value}>{opt.label}</option>
        ))}
      </select>

      <input
        value={zipCode}
        onChange={e => setZipCode(e.target.value)}
        pattern={zipPattern}
      />
    </div>
  );
}

联动逻辑需要:

  • 改变 country 时重置 state 和 zipCode
  • 根据 country 显示不同的 zip code 格式

这种联动逻辑在受控组件中很自然,在非受控组件中几乎无法实现。

场景三:需要动态禁用/启用输入

function FormWithTerms() {
  const [agreed, setAgreed] = useState(false);

  return (
    <div>
      <label>
        <input
          type="checkbox"
          checked={agreed}
          onChange={e => setAgreed(e.target.checked)}
        />
        I agree to terms
      </label>

      <button disabled={!agreed}>Submit</button>
    </div>
  );
}

按钮的 disabled 状态依赖 checkbox 的值,这天然是受控的。


什么时候用非受控组件

非受控组件适合这些场景:

场景一:文件上传

function FileUpload() {
  const fileRef = useRef(null);

  function handleSubmit(e) {
    e.preventDefault();
    const file = fileRef.current.files[0];
    uploadFile(file);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="file" ref={fileRef} />
      <button type="submit">Upload</button>
    </form>
  );
}

<input type="file"> 的值只能由用户选择,不能通过 JavaScript 设置。这使得它天然适合非受控模式。

场景二:集成第三方控件

当你使用第三方日期选择器、富文本编辑器等控件时,这些控件自己管理内部状态。强行改成受控模式可能需要大量工作:

function ThirdPartyDatePicker({ onDateChange }) {
  const pickerRef = useRef(null);

  function handleSubmit() {
    // 从第三方控件读取日期
    const date = datePickerRef.current.getDate();
    onDateChange(date);
  }

  return (
    <div>
      <DatePicker ref={datePickerRef} />
      <button onClick={handleSubmit}>Confirm</button>
    </div>
  );
}

场景三:性能敏感的简单表单

对于非常简单的表单,且不需要验证或联动,受控组件可能显得「过度工程」:

// 简单场景:只需要提交,不需要中间处理
function SimpleContactForm() {
  const nameRef = useRef(null);
  const messageRef = useRef(null);

  function handleSubmit() {
    sendForm({
      name: nameRef.current.value,
      message: messageRef.current.value
    });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input ref={nameRef} placeholder="Name" />
      <textarea ref={messageRef} placeholder="Message" />
      <button>Send</button>
    </form>
  );
}

但要注意:一旦你发现需要加验证或联动,就需要改成受控模式。


受控/非受控混用:部分受控

有时候你会遇到一个值同时支持 onChange 和 defaultValue

function PartiallyControlled({ value, onChange }) {
  return (
    <input
      value={value}
      onChange={e => onChange(e.target.value)}
      defaultValue="initial"
    />
  );
}

这个组件同时接收 valuedefaultValue

  • 如果传了 value,组件是受控的
  • 如果没传 value,组件是非受控的,初始值是 defaultValue

这种「部分受控」模式有一个术语:「受控组件优先」(controlled component is preferred)。

React 对这个的处理是:

  • 如果提供了 value,React 使用 value
  • 如果没有提供 valuevalue === undefined),React 使用 defaultValue 作为初始值,之后由 DOM 管理

为什么不推荐混用

混用容易导致困惑和 bug:

// 问题:defaultValue 会被忽略
<input value={undefined} defaultValue="initial" />
// React 会忽略 defaultValue,因为 value 不是 undefined

// 问题:value 和 onChange 不配套
<input
  value="hello"
  // 没有 onChange输入会被锁定用户无法输入
/>

正确的做法是:要么完全受控(value + onChange),要么完全非受控(ref 或 defaultValue),不要混用除非你明确知道自己在做什么。


defaultValue vs value:controlled vs uncontrolled 的分界线

理解 defaultValuevalue 的区别是理解受控/非受控的关键。

defaultValue(uncontrolled)

<input defaultValue="hello" />
  • 只设置初始值
  • 之后 DOM 完全控制这个输入框
  • 适合不需要追踪中间值的场景

value(controlled)

const [value, setValue] = useState('hello');
<input value={value} onChange={e => setValue(e.target.value)} />
  • React state 完全控制值
  • 每次输入都会触发状态更新
  • 适合需要追踪、验证、联动的场景

不要同时用 value 和 defaultValue

// 错误:混用导致行为不一致
<input value="hello" defaultValue="world" />
// React 会忽略 defaultValue,只使用 value
// 但如果没有 onChange,输入会被锁定

面试高频题:为什么 React 推荐受控组件

面试中经常会被问到:「既然非受控组件更简单,为什么 React 更推荐受控组件?」

原因一:数据流的可预测性

受控组件的数据流是单向的:用户输入 → onChange → state 更新 → 重新渲染。这个循环清晰可追踪。

// 数据流清晰
User types 'a'
  → onChange fires
    → setEmail('a')
      → component re-renders
        → value becomes 'a'

非受控组件的数据流是「隐式的」:用户输入 → DOM 内部更新 → 在某个时候(提交时)读取 ref.current.value。这个数据流没有在 React 的渲染循环中体现,难以追踪。

原因二:状态初始化的灵活性

受控组件可以在任何时候重置状态:

function ResetableForm() {
  const [email, setEmail] = useState('');

  function handleReset() {
    setEmail('');
  }

  return (
    <div>
      <input value={email} onChange={e => setEmail(e.target.value)} />
      <button onClick={handleReset}>Reset</button>
    </div>
  );
}

非受控组件要重置,需要操作 DOM:

// 非受控组件重置比较 hacky
function ResetableForm() {
  const emailRef = useRef(null);

  function handleReset() {
    emailRef.current.value = ''; // 直接操作 DOM
  }

  return (
    <div>
      <input ref={emailRef} defaultValue="" />
      <button onClick={handleReset}>Reset</button>
    </div>
  );
}

原因三:测试的便利性

受控组件的测试更容易,因为状态变化是通过 props 和回调函数进行的:

// 受控组件测试
render(<Input value="" onChange={handleChange} />);
fireEvent.change(input, { target: { value: 'test' } });
expect(handleChange).toHaveBeenCalledWith('test');

非受控组件测试需要模拟 DOM 交互:

// 非受控组件测试
render(<Input defaultValue="" />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'test' } });
expect(input.value).toBe('test');

原因四:与 React 的设计理念一致

React 的核心哲学是**「UI 是状态的函数」**:UI = f(state)。受控组件完美符合这个理念:给定一个状态,就有一个确定的 UI。非受控组件引入了 React 之外的「隐式状态」,与这个理念相悖。


常见误区

误区一:受控组件一定比非受控组件性能差

受控组件每次输入都触发重新渲染,但这不一定意味着性能差:

  • 现代 React 的 re-render 优化(如 React.memo、useMemo)可以避免不必要的重渲染
  • 对于简单的输入,性能差异通常可以忽略
  • 真正的性能问题往往来自其他方面(不必要的重渲染、大列表等)

误区二:所有表单都应该用受控组件

不一定。文件上传、第三方控件集成等场景,非受控组件更合适。

误区三:受控组件必须用 useState

不一定。可以用 useReducerZustandRedux 等任何状态管理方案。关键是值由某种「React 可追踪的状态」控制,而不是由 DOM 自己控制。


延展阅读