React 受控组件与非受控组件:从一个输入框讲清表单数据流

23 阅读7分钟

做表单时,我们经常会遇到两个看起来很像的写法:一种给输入框绑定 valueonChange,另一种只给它挂一个 ref,等提交时再读取内容。

两种写法都能拿到用户输入,但背后的数据流完全不同。真正需要判断的不是“哪种写法更高级”,而是:输入框当前的值,到底由 React state 管理,还是由 DOM 自己管理?

本文结合一个 React 19 表单示例,从单个输入框开始,逐步讲清受控组件、非受控组件、多字段表单和实时校验。

一、先抓住核心:谁是唯一数据源

在表单场景中,可以用一句话区分两类组件:

  • 受控组件:表单值保存在 React state 中,页面显示什么由 state 决定。
  • 非受控组件:表单值保存在 DOM 节点中,React 在需要时通过 ref 读取。

它们的输入过程可以简化为:

受控组件:用户输入 -> onChange -> 更新 state -> React 重新渲染 -> value 更新

非受控组件:用户输入 -> DOM 自己更新 value -> 点击提交 -> 通过 ref 读取

这个区别会继续影响实时校验、按钮状态、字段联动、重置方式以及组件的渲染行为。

二、受控组件:输入值始终经过 React

先看示例中的受控输入框:

import { useState } from 'react'

function ControlledInput() {
  const [value, setValue] = useState('')

  return (
    <>
      <label htmlFor="nickname">昵称</label>
      <input
        id="nickname"
        value={value}
        onChange={(event) => setValue(event.target.value)}
      />
      <p>当前输入:{value || '空字符串'}</p>
    </>
  )
}

这里有两个关键属性:

  • value={value}:输入框显示的内容来自 React state。
  • onChange={...}:用户每次输入时,将最新内容写回 state。

假设用户输入字母 A,过程如下:

  1. 浏览器触发 onChange
  2. 通过 event.target.value 取得 A
  3. setValue('A') 更新状态。
  4. 组件重新渲染。
  5. 新的 value="A" 再传给输入框。

因此,React state 是唯一数据源。我们既可以在输入框中显示这个值,也可以用它同步更新预览区、剩余字数或提交按钮。

为什么只写 value 不行

如果给输入框设置了 value,却没有在 onChange 中更新它:

<input value={value} />

用户虽然在键盘上输入了内容,但下一次渲染时,React 仍会把旧的 value 交给输入框。结果就是输入框看起来无法编辑。

所以,受控输入框通常成对出现的是 value + onChange,复选框则通常是 checked + onChange

三、非受控组件:让 DOM 保存当前值

再看非受控输入框:

import { useRef, useState } from 'react'

function UncontrolledInput() {
  const inputRef = useRef(null)
  const [submittedValue, setSubmittedValue] = useState('')

  const handleRead = () => {
    setSubmittedValue(inputRef.current.value)
  }

  return (
    <>
      <label htmlFor="message">留言</label>
      <input id="message" ref={inputRef} />
      <button type="button" onClick={handleRead}>
        读取 DOM 的值
      </button>
      <p>本次读取:{submittedValue || '尚未读取'}</p>
    </>
  )
}

这里没有给输入框绑定 value,用户输入后,内容直接保存在 DOM 节点中。inputRef.current 指向真实的 <input>,所以点击按钮时可以读取:

inputRef.current.value

需要特别注意:useRef 保存引用,但修改 ref.current 不会触发组件重新渲染。

输入过程中,DOM 的值一直在变化,React 并不会因此重新执行组件函数。示例中的 submittedValue 只是为了在页面上展示“上一次读取到的结果”,它不负责控制输入框。

初始值应该怎么设置

非受控组件如果需要初始值,应使用 defaultValue

<input ref={inputRef} defaultValue="默认昵称" />

defaultValue 只负责初始内容,后续输入仍由 DOM 管理。若改用 value,组件就进入了受控模式。

四、两种方案放在一起比较

对比项受控组件非受控组件
数据保存位置React stateDOM 节点
读取方式直接读取 state通过 ref.current.value
输入时是否更新 state
实时校验容易实现通常在读取或提交时处理
字段联动容易实现需要手动读取和同步
重置内容更新 state操作 DOM 或重置原生表单
与非 React 代码集成需要同步状态通常更直接
典型场景登录、注册、搜索筛选、动态表单简单提交、文件选择、接入旧页面

这里没有绝对的优劣。受控组件提供了更明确的数据流,非受控组件则减少了输入过程中的状态同步。

五、多个字段如何共用一个 change 处理函数

真正的业务表单通常不止一个字段。示例中的注册表单使用一个对象统一管理用户名和密码:

const initialForm = {
  username: '',
  password: '',
}

function RegisterForm() {
  const [form, setForm] = useState(initialForm)

  const handleChange = (event) => {
    const { name, value } = event.target

    setForm((previousForm) => ({
      ...previousForm,
      [name]: value,
    }))
  }

  return (
    <form>
      <input
        name="username"
        value={form.username}
        onChange={handleChange}
      />
      <input
        name="password"
        type="password"
        value={form.password}
        onChange={handleChange}
      />
    </form>
  )
}

关键是每个输入框的 name 与 state 中的字段名保持一致:

[name]: value

这是 JavaScript 的计算属性名。当用户名输入框发生变化时,nameusername;当密码输入框发生变化时,namepassword。这样就不必为每个字段重复编写一个处理函数。

这里使用函数式更新:

setForm((previousForm) => ({
  ...previousForm,
  [name]: value,
}))

它明确表示“基于上一次状态生成下一次状态”,在连续更新或逻辑继续扩展时更稳妥。

六、受控表单为什么适合实时校验

因为输入内容已经在 state 中,我们可以在每次变化后立即校验:

function validate(form) {
  const errors = {}

  if (!form.username.trim()) {
    errors.username = '请输入用户名'
  } else if (form.username.trim().length < 3) {
    errors.username = '用户名至少 3 个字符'
  }

  if (!form.password) {
    errors.password = '请输入密码'
  } else if (form.password.length < 6) {
    errors.password = '密码至少 6 个字符'
  }

  return errors
}

输入时计算下一份表单和错误信息:

const handleChange = (event) => {
  const { name, value } = event.target
  const nextForm = { ...form, [name]: value }

  setForm(nextForm)
  setErrors(validate(nextForm))
}

提交时再做一次完整校验:

const handleSubmit = (event) => {
  event.preventDefault()

  const nextErrors = validate(form)
  setErrors(nextErrors)

  if (Object.keys(nextErrors).length > 0) return

  console.log('提交表单', form)
}

最后,校验结果还可以控制按钮状态:

const isValid = Object.keys(validate(form)).length === 0

<button type="submit" disabled={!isValid}>
  提交注册
</button>

这就是受控组件的主要价值:同一份 state 同时驱动输入框、错误提示、按钮状态和最终提交数据,页面行为更容易推导。

七、不要在受控和非受控之间来回切换

下面这种写法容易产生问题:

const [value, setValue] = useState()

<input value={value} onChange={(e) => setValue(e.target.value)} />

初始的 valueundefined,输入框一开始相当于非受控;用户输入后,value 变成字符串,它又切换成了受控组件。

如果准备使用受控输入框,初始值也应是对应类型的有效值:

const [value, setValue] = useState('')

对于复选框,可以使用布尔值:

const [agreed, setAgreed] = useState(false)

<input
  type="checkbox"
  checked={agreed}
  onChange={(event) => setAgreed(event.target.checked)}
/>

八、文件输入框是一个典型例外

浏览器出于安全原因,不允许应用随意设置文件输入框的值。因此,文件输入通常按非受控方式读取:

function FilePicker() {
  const fileRef = useRef(null)

  const handleSubmit = () => {
    const file = fileRef.current.files[0]
    console.log(file)
  }

  return (
    <>
      <input ref={fileRef} type="file" />
      <button type="button" onClick={handleSubmit}>
        上传
      </button>
    </>
  )
}

这也说明了实际项目不必强行统一:同一个表单可以让文本字段受控,同时通过 ref 读取文件。

九、实际开发应该怎么选

以下情况通常优先使用受控组件:

  • 输入时就要校验并显示错误。
  • 一个字段会影响另一个字段。
  • 要根据内容禁用按钮或切换界面状态。
  • 要实现格式化、字符统计、搜索建议或实时预览。
  • 表单数据需要随时交给其他组件或业务逻辑使用。

以下情况可以考虑非受控组件:

  • 只在提交时读取一次值。
  • 表单非常简单,不需要实时反馈。
  • 需要接入依赖真实 DOM 的第三方代码。
  • 使用文件输入框。
  • 正在将旧的原生表单逐步迁移到 React。

还有一个实用判断:如果你频繁写 ref.current.value,随后又把结果同步进 state,很可能直接使用受控组件会更清晰。

十、总结

受控组件和非受控组件的本质,不在于有没有使用 Hook,而在于谁保存并决定当前值。

  • value + onChange + state 构成受控数据流。
  • ref + DOM value 构成非受控数据流。
  • 受控组件适合实时校验、字段联动和状态驱动的界面。
  • 非受控组件适合简单读取、DOM 集成和文件输入。
  • 多字段表单可以使用 name 与计算属性名复用更新逻辑。
  • 一旦选择受控模式,就应从初始渲染开始保持类型稳定。

表单写法没有固定答案。先确认数据需要在什么时候被读取、是否参与界面计算,再决定让 React 管理,还是让 DOM 暂时保管。