每个状态一个 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 深度原理见《状态属于谁?》