Skip to content

事件与受控表单:让输入值只有一个所有者 ​

问题:输入框、预览和提交记录为什么会不一致 ​

如果 DOM 自己保存输入值,而 React 又保存一份“名称”,两份数据可能分叉。受控输入把 value 交给 React state;浏览器事件只报告用户意图,state 更新后,新值再作为 prop 回到输入和预览。

第一次使用 state:值由 React 在两次渲染之间保存 ​

普通局部变量每次调用组件都会重新声明;改它也不会通知 React 重画页面。useState 则让 React 为当前组件保存一个值,并返回一对成员:当前渲染读到的值,以及请求更新的 setter。先独立运行这个 App.jsx:

jsx
import { useState } from 'react'

export default function App() {
  const [name, setName] = useState('茶')
  return (
    <label>
      名称
      <input value={name} onChange={event => setName(event.target.value)} />
      <span>当前名称:{name}</span>
    </label>
  )
}

输入“茶杯”后,输入框和旁边文本一起变化。React 再次调用 App,这次 useState 返回保存的新值,而不是重新初始化为“茶”。setter 发起更新,不会把已经在运行的函数里的 name 原地改掉;第二章会用连续点击和延迟回调观察这个区别。

useState 是 Hook。像 useState、后面会遇到的 useReducer、useMemo,都应在组件或自定义 Hook 的顶层、提前返回之前调用,不能放进条件、循环、事件处理器或普通辅助函数中。React 依靠稳定的调用顺序,把多次 Hook 调用与各自的数据对应起来。

例如有 readOnly prop 时,先写 const [name, setName] = useState(''),再写 if (readOnly) return <p>{name}</p>。不要把这次 Hook 调用放到 if (!readOnly) 内。事件处理器调用的是 setName,不是再调用一次 useState。本书后面的 use(resource) 是有单独条件调用规则的 API,不能把它的例外套到这些 state Hook 上。

完整实验:商品登记表 ​

jsx
import { useState } from 'react'

function Preview({ name, category }) {
  return (
    <section aria-label="预览">
      <h2>{name.trim() || '未命名商品'}</h2>
      <p>分类:{category}</p>
    </section>
  )
}

export default function App() {
  const [name, setName] = useState('')
  const [category, setCategory] = useState('drink')
  const [records, setRecords] = useState([])

  const normalizedName = name.trim()

  function handleSubmit(event) {
    event.preventDefault()
    if (!normalizedName) return

    setRecords(current => [
      ...current,
      { id: crypto.randomUUID(), name: normalizedName, category },
    ])
    setName('')
  }

  return (
    <main>
      <h1>登记商品</h1>
      <form onSubmit={handleSubmit}>
        <label>
          名称
          <input
            value={name}
            onChange={event => setName(event.target.value)}
          />
        </label>
        <label>
          分类
          <select
            value={category}
            onChange={event => setCategory(event.target.value)}
          >
            <option value="drink">饮品</option>
            <option value="tool">器具</option>
          </select>
        </label>
        <button type="submit" disabled={!normalizedName}>添加</button>
      </form>

      <Preview name={name} category={category} />

      <h2>已登记</h2>
      {records.length === 0 ? <p>暂无记录</p> : (
        <ul>
          {records.map(record => (
            <li key={record.id}>{record.name} / {record.category}</li>
          ))}
        </ul>
      )}
    </main>
  )
}

输入名称时,onChange 读取事件目标值并请求新渲染;新渲染把同一个 name 同时交给 <input value> 与 Preview。提交后,记录增加,名称输入被 state 清空。

setRecords(current => [...current, newRecord]) 使用函数式更新:React 把待处理的当前记录交给函数,函数返回一个新数组。展开保留已有条目,再追加新记录;不要用 records.push(...) 修改原数组。此处先会用“基于旧值生成新值”的模式,第二章再推演多个更新一起排队的顺序。id 在提交事件创建记录时生成一次,随后作为列表 key 使用。

事件处理器是稍后调用的函数 ​

onSubmit={handleSubmit} 传递函数引用。若写成 onSubmit={handleSubmit()},函数会在渲染时立刻执行,传给事件属性的是其返回值。这通常会导致提前副作用,若函数又设置 state,还可能造成重复渲染。

内联箭头函数 onClick={() => remove(id)} 也是传递函数:渲染时只创建它,点击时才调用 remove(id)。自定义组件的事件 prop 通常命名为 onSave,组件内部的处理函数命名为 handleSave,名称可以表达业务事件,不必局限于 DOM 事件名。

event.preventDefault() 阻止表单的浏览器默认提交导航;它不阻止事件冒泡。event.stopPropagation() 才阻止事件继续传到祖先。优先让表单使用 onSubmit,这样鼠标点击和键盘回车走同一条提交路径。

受控输入的数据流 ​

text
键盘输入
  → onChange(event)
  → setName(event.target.value)
  → React 再次调用 App
  → input.value 与 Preview 都读取新 name

state 放在同时需要它的最近共同祖先 App,这叫状态提升。Preview 不再保存一份重复名称,只从 props 计算展示。对每一项 state,都应有一个明确的所有者,而不是每个消费者各存一份副本。

改一个条件:把 value 去掉 ​

若改成 <input defaultValue={name}>,它只用初始值设置 DOM,之后 DOM 自己持有编辑值。提交后 setName('') 不会可靠清空该输入,因为它已经不再由 name 控制。

反过来,只写 value={name} 而没有 onChange,用户输入无法改变 state,输入看起来只读。受控输入的闭环必须同时有当前值和更新路径。

边界 ​

  • 文本输入的受控 value 应始终是字符串;不要在生命周期中从 undefined 切到字符串。初始化为 ''。
  • checkbox 使用 checked 与 event.target.checked,不是 value。
  • 文件输入由浏览器管理文件选择,不能像文本值一样用字符串完全控制。
  • 简单、只在提交时读取的表单可以使用非受控输入;需要即时校验、联动预览或程序化重置时,受控输入更直接。
  • 客户端禁用按钮改善交互,但不构成服务端校验或权限控制。

练习:带确认的通知设置 ​

实现一个完整 App.jsx,包含邮箱文本框、是否接收周报的 checkbox、预览和提交历史。

契约:

  • email 与 subscribed 都由 App state 控制;checkbox 必须使用 checked;
  • email 去除首尾空白后必须包含 @,否则显示错误并禁止提交;
  • 提交阻止页面刷新,把当时的 { id, email, subscribed } 追加到历史,再清空 email,但保留 subscribed;
  • 历史使用稳定 id 作为 key;初始空输入不能在控制/非控制模式间切换。

完成后对照答案 03。

下一篇 ​

列表、key 与状态身份

用心记录每一步成长