做表单时,我们经常会遇到两个看起来很像的写法:一种给输入框绑定 value 和 onChange,另一种只给它挂一个 ref,等提交时再读取内容。
两种写法都能拿到用户输入,但背后的数据流完全不同。真正需要判断的不是"哪种写法更高级",而是:输入框当前的值,到底由 React state 管理,还是由 DOM 自己管理?
本文结合一个 React 19 表单示例,从单个输入框开始,逐步讲清受控组件、非受控组件、多字段表单和实时校验。
一、先抓住核心:谁是唯一数据源
在表单场景中,可以用一句话区分两类组件:
- 受控组件:表单值保存在 React state 中,页面显示什么由 state 决定。
- 非受控组件:表单值保存在 DOM 节点中,React 在需要时通过
ref读取。
它们的输入过程可以简化为:
text
受控组件:用户输入 -> onChange -> 更新 state -> React 重新渲染 -> value 更新
非受控组件:用户输入 -> DOM 自己更新 value -> 点击提交 -> 通过 ref 读取
这个区别会继续影响实时校验、按钮状态、字段联动、重置方式以及组件的渲染行为。
二、受控组件:输入值始终经过 React
先看示例中的受控输入框:
jsx
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,过程如下:
- 浏览器触发
onChange。 - 通过
event.target.value取得A。 setValue('A')更新状态。- 组件重新渲染。
- 新的
value="A"再传给输入框。
因此,React state 是唯一数据源。我们既可以在输入框中显示这个值,也可以用它同步更新预览区、剩余字数或提交按钮。
为什么只写 value 不行
如果给输入框设置了 value,却没有在 onChange 中更新它:
jsx
<input value={value} />
用户虽然在键盘上输入了内容,但下一次渲染时,React 仍会把旧的 value 交给输入框。结果就是输入框看起来无法编辑。
所以,受控输入框通常成对出现的是 value + onChange,复选框则通常是 checked + onChange。
三、非受控组件:让 DOM 保存当前值
再看非受控输入框:
jsx
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>,所以点击按钮时可以读取:
js
inputRef.current.value
需要特别注意:useRef 保存引用,但修改 ref.current 不会触发组件重新渲染。
输入过程中,DOM 的值一直在变化,React 并不会因此重新执行组件函数。示例中的 submittedValue 只是为了在页面上展示"上一次读取到的结果",它不负责控制输入框。
初始值应该怎么设置
非受控组件如果需要初始值,应使用 defaultValue:
jsx
<input ref={inputRef} defaultValue="默认昵称" />
defaultValue 只负责初始内容,后续输入仍由 DOM 管理。若改用 value,组件就进入了受控模式。
四、两种方案放在一起比较
| 对比项 | 受控组件 | 非受控组件 |
|---|---|---|
| 数据保存位置 | React state | DOM 节点 |
| 读取方式 | 直接读取 state | 通过 ref.current.value |
| 输入时是否更新 state | 是 | 否 |
| 实时校验 | 容易实现 | 通常在读取或提交时处理 |
| 字段联动 | 容易实现 | 需要手动读取和同步 |
| 重置内容 | 更新 state | 操作 DOM 或重置原生表单 |
| 与非 React 代码集成 | 需要同步状态 | 通常更直接 |
| 典型场景 | 登录、注册、搜索筛选、动态表单 | 简单提交、文件选择、接入旧页面 |
这里没有绝对的优劣。受控组件提供了更明确的数据流,非受控组件则减少了输入过程中的状态同步。
五、多个字段如何共用一个 change 处理函数
真正的业务表单通常不止一个字段。示例中的注册表单使用一个对象统一管理用户名和密码:
jsx
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 中的字段名保持一致:
js
[name]: value
这是 JavaScript 的计算属性名。当用户名输入框发生变化时,name 是 username;当密码输入框发生变化时,name 是 password。这样就不必为每个字段重复编写一个处理函数。
这里使用函数式更新:
js
setForm((previousForm) => ({
...previousForm,
[name]: value,
}))
它明确表示"基于上一次状态生成下一次状态",在连续更新或逻辑继续扩展时更稳妥。
六、受控表单为什么适合实时校验
因为输入内容已经在 state 中,我们可以在每次变化后立即校验:
jsx
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
}
输入时计算下一份表单和错误信息:
jsx
const handleChange = (event) => {
const { name, value } = event.target
const nextForm = { ...form, [name]: value }
setForm(nextForm)
setErrors(validate(nextForm))
}
提交时再做一次完整校验:
jsx
const handleSubmit = (event) => {
event.preventDefault()
const nextErrors = validate(form)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
console.log('提交表单', form)
}
最后,校验结果还可以控制按钮状态:
jsx
const isValid = Object.keys(validate(form)).length === 0
<button type="submit" disabled={!isValid}>
提交注册
</button>
这就是受控组件的主要价值:同一份 state 同时驱动输入框、错误提示、按钮状态和最终提交数据,页面行为更容易推导。
七、不要在受控和非受控之间来回切换
下面这种写法容易产生问题:
jsx
const [value, setValue] = useState()
<input value={value} onChange={(e) => setValue(e.target.value)} />
初始的 value 是 undefined,输入框一开始相当于非受控;用户输入后,value 变成字符串,它又切换成了受控组件。
如果准备使用受控输入框,初始值也应是对应类型的有效值:
jsx
const [value, setValue] = useState('')
对于复选框,可以使用布尔值:
jsx
const [agreed, setAgreed] = useState(false)
<input
type="checkbox"
checked={agreed}
onChange={(event) => setAgreed(event.target.checked)}
/>
八、文件输入框是一个典型例外
浏览器出于安全原因,不允许应用随意设置文件输入框的值。因此,文件输入通常按非受控方式读取:
jsx
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 暂时保管。