同一个组件既要受控又要非受控?我写了个双模式开关,还挺优雅的
那天下午,我在代码审查时盯着同事写的 useEffect 看了整整三分钟。这玩意用 useEffect 同步 value 和内部 state,中间还夹了个 useRef 来防止死循环——就为了一个输入框能同时支持 value 和 defaultValue。
“你这是在写组件还是在拆弹?”我问。
他摊手:“不然呢?产品说这个字段编辑的时候从服务端回填,新增的时候用户自己填。我总不能写两个组件吧。”
这事我遇到过太多次了。React 生态里,受控和非受控就像两个平行宇宙,大多数组件库要么让你二选一,要么给你两个不同的 API。但真实业务里,同一个字段有时候需要受控,有时候需要非受控——编辑页受控,新建页非受控,或者某个表单字段根据开关切换模式。
我当时给同事的方案是:用一个自定义 Hook 把两套逻辑收进一个状态机,组件本身只需要消费它暴露的接口,完全不用关心自己处于哪种模式。
function useDualMode<T>({
value,
defaultValue,
onChange,
}: {
value?: T;
defaultValue?: T;
onChange?: (value: T) => void;
}) {
const isControlled = value !== undefined;
const isControlledRef = useRef(isControlled);
// 更新 controlled 标记的 ref
isControlledRef.current = isControlled;
const [internalValue, setInternalValue] = useState<T>(
() => (isControlled ? value : defaultValue) as T
);
// 只在受控模式下响应外部 value 变化
useEffect(() => {
if (isControlledRef.current) {
setInternalValue(value as T);
}
}, [value]);
const setValue = useCallback((nextValue: T) => {
if (!isControlledRef.current) {
setInternalValue(nextValue);
}
onChange?.(nextValue);
}, [onChange]);
return [internalValue, setValue] as const;
}
这个 Hook 的核心逻辑就三条:
第一,用 value === undefined 判断模式。React 官方其实也是用这个规则——你传了 value 就是受控,没传就是非受控。不需要额外的 mode prop,调用方甚至不需要知道有“模式”这回事。
第二,永远维护内部 state,但受控模式下它只是外部 value 的镜像。useEffect 在受控时把外部值同步进来,非受控时 useEffect 虽然有依赖 value,但因为 value 始终是 undefined,实际上不会触发同步。
第三,setValue 在非受控时更新内部 state,受控时只调 onChange。这是关键——受控组件的值由父组件决定,你不能自己改自己的 state,否则会出现短暂的不一致。但 onChange 还是要调的,通知父组件“我变了”。
实际用起来长这样:
function Input(props: {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
}) {
const [value, setValue] = useDualMode(props);
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
调用方完全不用动脑子:
// 受控模式
<Input value={name} onChange={setName} />
// 非受控模式
<Input defaultValue="张三" onChange={handleChange} />
// 甚至可以在同一个表单里混用
<Input value={editingName} onChange={setEditingName} />
<Input defaultValue="" onChange={handleRemark} />
但这版代码有个坑,同事第二天就踩到了。他把 defaultValue 从 undefined 改成 "" 再改回 undefined,发现输入框里之前输入的内容没了。
原因是 useState 的初始值只在首次渲染时生效。defaultValue 变了,但组件已经挂载了,useState 不会重新初始化。
React 官方对这个问题的态度是:非受控组件的 defaultValue 本来就不该变,变了也不保证生效。但真实业务里,你可能会遇到“表单重置”这种场景,需要让非受控组件回到初始状态。
解决方案是在 Hook 里加一个 key 机制,或者暴露一个 reset 方法:
function useDualMode<T>({
value,
defaultValue,
onChange,
}: {
value?: T;
defaultValue?: T;
onChange?: (value: T) => void;
}) {
const isControlled = value !== undefined;
const isControlledRef = useRef(isControlled);
isControlledRef.current = isControlled;
const [internalValue, setInternalValue] = useState<T>(
() => (isControlled ? value : defaultValue) as T
);
// 记录上一次的 defaultValue,变了就重置
const prevDefaultValue = useRef(defaultValue);
useEffect(() => {
if (
!isControlledRef.current &&
defaultValue !== prevDefaultValue.current
) {
prevDefaultValue.current = defaultValue;
setInternalValue(defaultValue as T);
}
}, [defaultValue]);
useEffect(() => {
if (isControlledRef.current) {
setInternalValue(value as T);
}
}, [value]);
const setValue = useCallback((nextValue: T) => {
if (!isControlledRef.current) {
setInternalValue(nextValue);
}
onChange?.(nextValue);
}, [onChange]);
return [internalValue, setValue] as const;
}
这里用 useRef 存上一次的 defaultValue,变了就重置内部 state。这个行为更符合直觉——你改了默认值,组件就应该回到新的默认值。
不过说实话,这种 defaultValue 变化重置的场景不多见。大多数时候,你直接给组件换个 key 更简单粗暴:
<Input key={resetFlag} defaultValue={initialValue} />
key 一变,React 直接销毁重建组件,什么 state 都清零了。
回到这个 Hook 本身。我发现写好之后,组件的逻辑反而比纯受控或纯非受控更清晰了。因为所有“值从哪来、写回哪去”的决策都集中在一个地方,组件本身只是傻傻地从 Hook 拿值和 setter。
后来我又给它加了 TypeScript 的类型约束——当 value 存在时,defaultValue 应该是可选的;当 value 不存在时,defaultValue 应该是必填的。不过这个类型体操有点复杂,我放在了项目里的 types.ts 里,有兴趣的话可以单开一篇聊。
常见问题
受控和非受控同时存在不会冲突吗?
不会,因为判断逻辑是互斥的——value !== undefined 就是受控,否则就是非受控。不存在“同时”的情况。组件内部始终维护一个 state,但受控模式下这个 state 由外部 value 驱动,非受控模式下由用户操作驱动。setValue 在受控时不修改内部 state,只通知外部。
如果 defaultValue 是个对象或数组,改了内部属性会触发重置吗?
不会,因为比较用的是 !== 引用比较。如果你改了对象内部属性但引用没变,Hook 感知不到。这是故意设计的——真要那么细粒度的检测,性能开销不划算。建议在调用侧通过换引用或换 key 来触发重置。
这个 Hook 能用在所有表单组件上吗?
能,只要组件遵循 value/onChange 的接口约定就行。输入框、下拉框、日期选择器、自定义的富文本编辑器——都是一样的模式。如果组件需要的值类型不是简单类型,比如富文本的 ContentState,泛型 T 也能兜住。