我受够了 value / defaultValue / onChange 三连——一个 prop 重构 React 受控组件

每个状态一个 prop。本文介绍一种替换受控组件三连 prop 的新模式,附基准测试数据。

如果你给 <Select><DatePicker><Dialog> 写过包装组件,你一定写过这样的代码:

jsx 复制代码
function Select({ value, defaultValue, onChange }) {
  const [internal, setInternal] = useState(defaultValue);
  const isControlled = value !== undefined;          // 运行时判断模式
  const current = isControlled ? value : internal;   // 两个数据源

  useEffect(() => {
    if (isControlled) setInternal(value);            // 手工同步
  }, [value, isControlled]);
  // ...
}

一个状态,三个 prop,两个数据源,一个同步 effect,外加无处不在的分支。 组件有 3 个可控状态(value、open、selectedIndex)时,就是 9 个 prop、3 处分支、3 个 effect

我用一个 80 行的零依赖库 react-use-control 解决了这个问题,思路和现有方案都不一样。

换个问题:状态从哪里来?

受控/非受控二元性的根源,在于我们在问"父组件是否控制这个状态?"然后分支。 真正的问题是:这个状态从哪里来?

  • 父组件已经创建了 → 子组件直接用
  • 没人创建 → 子组件自己创建

没有两个数据源,就不需要同步,也就不需要模式判断。这就是 control 对象做的事:

jsx 复制代码
function Counter({ count }) {
  const [num, setNum] = useControl(count, 0); // count 是 control 或普通值
  return <button onClick={() => setNum((n) => n + 1)}>{num}</button>;
}
  • 传普通值 → 非受控,该值即默认值
  • 传 control → 受控,父子共享同一个数据源
  • 两条路走的是同一条代码路径,组件内零分支

这不是 signal

形似 Solid/Jotai------都是到处传令牌。但机制完全不同:

Signal(Solid/Jotai) react-use-control
状态在哪里 组件树之外,模块级 store 组件树之内,普通 useState
更新如何流动 订阅 + 细粒度重渲染 React 常规 setState 渲染流
生命周期 全局存活,泄漏需自管 随宿主组件消亡
模块级创建状态 不能

control 不是状态容器,是定位器 :找到祖先已创建的 useState,或者现在就创建。 React 依旧是 store 和调度器,所以 SSR、StrictMode、并发渲染天然可用,没有订阅机制、 没有清理、没有泄漏隐患。代价是状态必须挂在组件上------模块级共享状态不是它的场景。

额外收获:兄弟共享和中间件

同一个 control 传给多个子组件,它们共享同一份状态,无需 Context、无需状态提升:

jsx 复制代码
const [, setCount, countCtl] = useControl(0);
<Counter count={countCtl} />
<Counter count={countCtl} />   {/* 两个计数器和父级共享同一份状态 */}

中间层想对状态做变换(限幅、翻倍、埋点),useThru 直接移除父组件自己的状态------ 包装层只提供转换,子组件仍是状态的管理者和触发者:

jsx 复制代码
const clamped = useThru(count, mapSetter((v) => Math.max(0, v)));  // 写入前限幅
const shown = useThru(clamped, mapState((v) => `$${v}`));          // 读取时映射
const logged = useThru(shown, watch(console.log));                 // 写入埋点

value/onChange 模式下同样的链条,每一层都要一份父级状态和一个手工包装的回调。

基准测试(jsdom,DOM 产出完全一致)

场景 手写三连 Radix useControllableState react-use-control
挂载(非受控) 基线 1.31× 1.38×
受控 prop 更新 基线 2.26× 2.67×
setter 点击更新 基线 1.09× 0.99×(持平)

受控更新是架构真正拉开差距的地方:手写和 Radix 都有第二份待同步的数据源; control 直接读单一数据源。setter 路径由 React 自身 dispatch 主导,三方持平------ 数据如实标注,不粉饰。复现:pnpm bench

什么时候不该用

表单库管理的字段(react-hook-form、formik 等)别用------状态归表单库所有,字段是 永久受控的,读和写都走表单库 API。control 解决的是所有权问题,问题不存在的地方 它什么也加不了。

小结

  • 每个状态一个 prop:Control<T> | T,传值是默认值,传 control 是共享
  • 单一数据源:无同步 effect、无 isControlled 分支、无 uncontrolled→controlled 告警
  • 80 行、0 依赖,只用 useState/useMemo/useRef,有 hooks 就能跑
  • 已被 haze-ui 全组件库采用

仓库:react-use-control · npm: npm i react-use-control 深度原理见《状态属于谁?

相关推荐
BreezeJiang26 分钟前
为什么生成了 dist,项目还是可能跑不起来?从一个 React + Express + MySQL 项目看部署闭环
nginx·react.js
浮生望11 小时前
React JWT 登录鉴权实战:Zustand 状态管理 + Axios 拦截器 + 路由守卫完整链路
react.js
光影少年19 小时前
react navite 安卓iOS 打包、签名、环境区分
前端·react native·react.js
会编程的土豆1 天前
从零理解 AI Agent:概念、ReAct、Prompt 五要素与面试要点
人工智能·react.js·prompt
zzzzzz3101 天前
react-bits:把酷炫动效从“灵感截图”变成可复用的 React 组件
javascript·react.js·动效
YHHLAI2 天前
React + TypeScript 实战:从零构建颜色选择器应用
前端·react.js·typescript
光影少年2 天前
react navite调试方案:Flipper、远程调试
前端·javascript·react native·react.js·前端框架
YHHLAI2 天前
[特殊字符] 从 JSX 到 TypeScript:React 开发的进化之路
javascript·react.js·typescript