同一个组件既要受控又要非受控?我写了个双模式开关,还挺优雅的

那天下午,我在代码审查时盯着同事写的 useEffect 看了整整三分钟。这玩意用 useEffect 同步 value 和内部 state,中间还夹了个 useRef 来防止死循环——就为了一个输入框能同时支持 valuedefaultValue

“你这是在写组件还是在拆弹?”我问。

他摊手:“不然呢?产品说这个字段编辑的时候从服务端回填,新增的时候用户自己填。我总不能写两个组件吧。”

这事我遇到过太多次了。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} />

但这版代码有个坑,同事第二天就踩到了。他把 defaultValueundefined 改成 "" 再改回 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 也能兜住。