React的两种状态哲学:受控与非受控模式

原生html元素受控和非受控模式

在 React 中,表单组件(如 <input><textarea><select> 等)有两种处理用户输入的方式:受控组件(Controlled Components) 和 非受控组件(Uncontrolled Components)。

受控组件 (Controlled Components)

定义:表单元素的值完全由React组件状态驱动,形成单向数据流

核心特征

  • 数据源唯一性:value属性绑定组件state
  • 严格同步机制:必须通过onChange事件更新state
  • 完全状态控制:每次输入都触发重新渲染
js 复制代码
。
function ControlledInput() {
  const [inputValue, setInputValue] = useState("");

  return (
    <div>
      <input
        type="text"
        value={inputValue} // 受控
        onChange={(e) => setInputValue(e.target.value)} // 更新 state
      />
      <p>输入值: {inputValue}</p>
    </div>
  );
}

export default ControlledInput;

非受控组件 (Uncontrolled Components)

定义:非受控组件是指表单元素的值由 DOM 自己管理,而不是由 React state 控制。通常使用 ref 直接访问和操作 DOM 元素。

特点:

  • 数据存储在 DOM 中,而不是 React 组件 state 中。
  • 文件上传等不需要即时验证的输入
  • 适用于与非 React 代码(如第三方库)集成的场景。
js 复制代码
import { useRef } from "react";

function UncontrolledInput() {
  const inputRef = useRef(null);

  const handleSubmit = () => {
    alert(`输入值: ${inputRef.current.value}`);
  };

  return (
    <div>
      <input type="text" ref={inputRef} /> {/* 非受控 */}
      <button onClick={handleSubmit}>提交</button>
    </div>
  );
}

export default UncontrolledInput;

在实际开发中,很多第三方组件库(如 antd)的表单组件都支持受控和非受控两种模式

  • <Input value={value} onChange={setValue} />(受控模式)
  • <Input defaultValue="初始值" ref={inputRef} /> 或者 <Input defaultValue="初始值" onChange={读取数据}> (非受控模式)

组件级别的受控和非受控模式

当从原生html元素的受控/非受控模式切换到组件级别的受控/非受控模式时,具体的控制逻辑也是相似的。

受控组件模式

父组件通过props完全控制子组件状态

特征

  • 父组件传入valueonChange
  • 子组件成为无状态(pure)组件
  • 数据流完全可预测

非受控组件模式

子组件自主管理状态,父组件通过defaultValue初始化

特征

  • 父组件传入defaultValue
  • 子组件内部维护状态
  • 通过onChange通知父组件状态变更

实现一个同时支持受控模式和非受控模式的子组件Calendar

tsx 复制代码
function Calendar(props: CalendarProps) {
  // 受控模式:父组件传入value和onChange
  // 非受控模式:父组件传入defaultValue和onChange
  const { value: propsValue, defaultValue, onChange, dates } = props;

  // 初始化内部状态(非受控模式使用)
  const [internalValue, setInternalValue] = useState<Date | undefined>(propsValue ?? defaultValue);

  // 处理日期选择
  const changeValue = (date: Date) => {
    if (propsValue === undefined) {
      setInternalValue(date); // 仅非受控模式更新内部状态
    }
    onChange?.(date); 
  };

  // 最终显示值
  const mergedValue = propsValue ?? internalValue;

  return (
    <div>
      <div>当前选中:{mergedValue?.toLocaleDateString() || "未选择"}</div>
      {dates.map((date) => (
        <div key={date.toISOString()} onClick={() => changeValue(date)} style={{ cursor: "pointer", margin: "8px 0" }}>
          {date.toLocaleDateString()}
        </div>
      ))}
    </div>
  );
}
tsx 复制代码
// 受控模式调用子组件
export default function App() {
  const dates = [new Date(2025, 4, 1), new Date(2025, 4, 2), new Date(2025, 4, 3)];
  const [date, setDate] = useState(new Date(2025, 4, 1));
  return (
    <Calendar dates={dates} value={date} onChange={(date) => {setDate(date); }}
    />
  );
}

// 非受控模式调用子组件
export default function App() {
  const dates = [new Date(2025, 4, 1), new Date(2025, 4, 2), new Date(2025, 4, 3)];
  return (
    <Calendar dates={dates} onChange={(date) => {console.log(date);}}
      defaultValue={new Date(2025, 4, 1)}
    />
  );
}

考虑edge case:

  1. 非受控转受控:如果组件开始时是非受控的(没有传入value),后来父组件传入了value,此时组件会切换为受控模式。此时internalValue会被保留,但由于mergedValue优先使用propsValue,所以UI会正确显示父组件传入的value。
  2. 受控转非受控 :当组件从受控模式切换到非受控模式时,比如父组件之前传了value,后来不传了,这时候internalValue却不会更新,因为此时 propsValue变为undefined,mergedValue 会取 internalValue,而 internalValue 可能还是之前的value,这可能导致错误展示。

所以需要增加useEffect监听propsValue的变化,同步受控模式的外部值变化。

tsx 复制代码
function Calendar(props: CalendarProps) {

  const { value: propsValue, defaultValue, onChange, dates } = props;

  // 初始化内部状态(非受控模式使用)
  const [internalValue, setInternalValue] = useState<Date | undefined>(propsValue ?? defaultValue);

  // ===同步受控模式的外部值变化===
  useEffect(() => {
    if (propsValue !== undefined) {
      setInternalValue(propsValue);
    }
  }, [propsValue]);
  // =============================
  
  // 处理日期选择
  const changeValue = (date: Date) => {
    if (propsValue === undefined) {
      setInternalValue(date); // 仅非受控模式更新内部状态
    }
    onChange?.(date); 
  };

  // 最终显示值
  const mergedValue = propsValue ?? internalValue;

  return (
   ......
  );
}

但是这可能导致性能问题。

propsValue 变化时,父组件触发子组件渲染 → useEffectsetInternalValue 再次触发子组件渲染,导致 ​单次外部变化引发两次渲染 。所以将useEffect改成只有新旧值不同时才更新

tsx 复制代码
import { isEqual } from "lodash";

useEffect(() => {
  if (propsValue === undefined) return;
  setInternalValue((prev) => { // 值未变化时返回 prev 以跳过渲染
    return isEqual(prev, propsValue) ? prev : propsValue;
  });
}, [propsValue]);

知乎大佬提出用 Ref + forceUpdate() 来解决这个问题,可以参考:zhuanlan.zhihu.com/p/536322574

封装hook

但简单起见,直接用useEffect这种好理解的方案进行hook封装。

tsx 复制代码
import { isEqual } from "lodash";

type Options<T> = {
  value?: T;
  defaultValue?: T;
  onChange?: (data: T) => void;
};
export function useMergeState<T>(options: Options<T>) {
  const { defaultValue, value: propValue, onChange } = options || {};

  const [internalValue, setInternalValue] = useState<T | undefined>(propValue ?? defaultValue);

  // 同步受控模式的外部值变化
  useEffect(() => {
    if (propValue === undefined) return;
    setInternalValue((prev) => {
      // 值未变化时返回 prev 以跳过渲染
      return isEqual(prev, propValue) ? prev : propValue;
    });
  }, [propValue]);

  const updateValue = (newValue: T) => {
    if (propValue === undefined) {
      setInternalValue(newValue);
    }
    onChange?.(newValue);
  };
  const mergedValue = propValue ?? internalValue;
  return [mergedValue, updateValue] as const;
}

仅当值变化时触发onChange

但是现在的封装还存在一个问题,只要用户触发了点击,无论值是否存在变化,都会调用onChange。

需要做一下判断,只有新旧值不同时,才触发 onChange 和状态更新。在 updateValue 中,通过 mergedValueRef.current 获取最新值,使用 isEqual 进行深比较。

diff 复制代码
import { isEqual } from "lodash";

type Options<T> = {
  value?: T;
  defaultValue?: T;
  onChange?: (data: T) => void;
};

export function useMergeState<T>(options: Options<T>) {
  const { defaultValue, value: propValue, onChange } = options || {};

  const [internalValue, setInternalValue] = useState<T | undefined>(propValue ?? defaultValue);
  const mergedValue = propValue ?? internalValue;
+  const mergedValueRef = useRef<T | undefined>(mergedValue);
+  mergedValueRef.current = mergedValue; 

  // 同步受控模式的外部值变化
  useEffect(() => {
    if (propValue === undefined) return;
    setInternalValue((prev) => {
      // 值未变化时返回 prev 以跳过渲染
      return isEqual(prev, propValue) ? prev : propValue;
    });
  }, [propValue]);

  const updateValue = (newValue: T) => {
+   if (isEqual(mergedValueRef.current, newValue)) return;

    if (propValue === undefined) {
      setInternalValue(newValue);
    }
    onChange?.(newValue);
  };
  return [mergedValue, updateValue] as const;
}

最后在子组件调用useMergeState

tsx 复制代码
function Calendar(props: CalendarProps) {
  const { value, defaultValue, onChange, dates } = props;
  const [date, updateValue] = useMergeState({ value, defaultValue, onChange });
  return (
    <div>
      <div>当前选中:{date?.toLocaleDateString() || "未选择"}</div>
      {dates.map((d) => (
        <div key={d.toISOString()} onClick={() => updateValue(d)} >
          {d.toLocaleDateString()}
        </div>
      ))}
    </div>
  );
}

总结

在 React 开发中,​受控与非受控模式 是管理组件状态的核心设计思想,其本质区别在于数据控制权的归属

  • 受控模式 通过 valueonChange 将数据流完全交给父组件,适合需要精准控制状态(如实时表单验证、动态交互)的场景,确保数据流的可预测性。
  • 非受控模式 通过 defaultValueref 让组件自主管理状态,适用于性能敏感场景(如表单初始化后无需频繁更新)。

参考:

  1. juejin.cn/book/729408...
  2. zhuanlan.zhihu.com/p/536322574
相关推荐
Mintopia4 分钟前
一个月速成 AI 工程师:从代码小白到智能工匠的修炼手册
前端·javascript·aigc
Mintopia7 分钟前
Next.js 全栈:接收和处理请求
前端·javascript·next.js
袁煦丞41 分钟前
2025.8.18实验室【代码跑酷指南】Jupyter Notebook程序员的魔法本:cpolar内网穿透实验室第622个成功挑战
前端·程序员·远程工作
Joker Zxc1 小时前
【前端基础】flex布局中使用`justify-content`后,最后一行的布局问题
前端·css
无奈何杨1 小时前
风控系统事件分析中心,关联关系、排行、时间分布
前端·后端
Moment1 小时前
nginx 如何配置防止慢速攻击 🤔🤔🤔
前端·后端·nginx
晓得迷路了1 小时前
栗子前端技术周刊第 94 期 - React Native 0.81、jQuery 4.0.0 RC1、Bun v1.2.20...
前端·javascript·react.js
前端小巷子1 小时前
Vue 自定义指令
前端·vue.js·面试
玲小珑1 小时前
Next.js 教程系列(二十七)React Server Components (RSC) 与未来趋势
前端·next.js