React 状态归属:两个版本的用户名编辑器,告诉你 state 该放哪

React 状态归属:两个版本的用户名编辑器,告诉你 state 该放哪

推荐理由:用一个项目的两次重构,把 React 组件设计最核心的问题------"state 归谁管"------讲得明明白白。

你写了一个用户名编辑组件。功能很简单:页面显示当前用户名,有个输入框可以编辑,点"Update"提交。

写完 V1,你看着 4 个 props 愣了一秒。然后你重写了 V2,同样的功能,props 从 4 个变成 2 个,父组件从 30 行变成 15 行。

这篇文章就是讲清楚:为什么同样的功能,换个 state 归属,代码质量天差地别。


两个版本,同一功能

先看效果------两个版本跑起来一模一样:

复制代码
页面显示:Hello 用户名
输入框:可以编辑
按钮:点击提交 → 父组件收到新名字

区别全在代码架构上。

V1:父组件掌管一切

tsx 复制代码
// App.tsx --- 父组件包揽了所有 state
const App = () => {
  const [name, setName] = React.useState<string>('defaultUserName');
  const [editingName, setEditingName] = React.useState<string>('defaultUserName');

  const setUsernameState = () => {
    setName(editingName); // 🔑 父组件自己提交:从 editingName → name
  };

  return (
    <>
      名字: {name}
      <HelloComponent userName={editingName} />
      <NameEditingComponent
        editingName={editingName}           // 往下传编辑值
        onNameUpdated={setUsernameState}     // 往下传提交回调
        onEditingNameUpdated={setEditingName} // 往下传修改回调
        disabled={editingName === '' || editingName === name} // ⚠️ 父组件算 disabled
      />
    </>
  );
};

对应的 NameEditingComponent 长这样:

tsx 复制代码
// NameEditingComponent.tsx --- 没有自己的 state,完全受控
interface Props {
  editingName: string;
  onNameUpdated: () => void;
  onEditingNameUpdated: (newName: string) => void;
  disabled: boolean;
}

const NameEditingComponent: React.FC<Props> = (props) => {
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    onEditingNameUpdated(e.target.value); // 直接回调父组件,自己不存
  };
  const onNameSubmit = () => {
    onNameUpdated(); // 无参------值已经在父组件里了
  };
  return (
    <>
      <label>Update name</label>
      <input value={editingName} onChange={onChange} />
      <button disabled={disabled} onClick={onNameSubmit}>Update</button>
    </>
  );
};

4 个 props,0 个自有 state。子组件是个"传声筒"------所有逻辑都在父组件。

V2:子组件自己管自己

tsx 复制代码
// App2.tsx --- 父组件只管最终值
const App: React.FC = () => {
  const [username, setUserName] = React.useState('initialName');

  return (
    <div>
      <Hello userName={username} />
      <NameEditComponent
        initialUsername={username}     // 初始值进去
        onNameUpdated={setUserName}    // 新值出来
      />
    </div>
  );
};

父组件只剩 2 个 props。编辑过程的中间状态去哪了?进子组件了:

tsx 复制代码
// NameEditComponent.tsx --- 拥有自己的编辑状态
interface Props {
  initialUsername: string;
  onNameUpdated: (newName: string) => void;
}

const NameEditComponent: React.FC<Props> = (props) => {
  // 🔑 关键:编辑状态归子组件自己管
  const [editingName, setEditingName] = React.useState(props.initialUsername);

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEditingName(e.target.value); // 更新自己的 state
  };

  const onNameSubmit = () => {
    props.onNameUpdated(editingName); // 提交时把最终值告诉父组件
  };

  return (
    <div>
      <label>Update name:</label>
      <input value={editingName} onChange={onChange} />
      <button onClick={onNameSubmit}>Update</button>
    </div>
  );
};

2 个 props,1 个自有 state。子组件不再是传声筒,而是一个能独立工作的单元。


核心概念:State Ownership(状态归属)

状态归属原则:state 应该放在离使用它的地方最近的组件里。 如果一个状态只影响一个组件的行为,它就应该属于那个组件。

这条原则的反面就是 V1:父组件拿着一个它自己不需要的 state(editingName),只是为了传给子组件。这就像你帮同事保管他私人的记事本------你不需要,但每次他要用的时候你得递过去。

Mermaid:两种架构的数据流对比

graph TB subgraph V1[&#34;❌ V1 --- 父组件管控一切&#34;] A1[&#34;App state:<br/>name, editingName&#34;] A2[&#34;NameEditingComponent<br/>(无 state,纯传声筒)&#34;] A1 -->|editingName| A2 A1 -->|setEditingName| A2 A1 -->|setUsernameState| A2 A1 -->|disabled 计算| A2 end subgraph V2[&#34;✅ V2 --- 各自管各自&#34;] B1[&#34;App state:<br/>username&#34;] B2[&#34;NameEditComponent<br/>state: editingName&#34;] B1 -->|initialUsername| B2 B2 -->|onNameUpdated| B1 end

一张图就看清楚:V1 是 4 条线全从父组件出去,V2 是父和子各管各的,只有 2 条线做交接。


为什么 V1 的设计有问题?

三个具体问题,每个都值得认真看。

问题一:父组件知道太多

typescript 复制代码
// ⚠️ 父组件在计算 disabled------这件事跟父组件有什么关系?
disabled={editingName === '' || editingName === name}

"按钮能不能点"这个判断,本质上是编辑组件自己的事。父组件只需要知道"用户提交了新名字"就够了。父组件不应该知道编辑过程的中间状态。

这种"父组件知道太多"的设计,随着功能增加会雪崩------下次你要加"输入长度限制"、"特殊字符校验"、"实时保存",所有逻辑全堆在父组件,它就成了"上帝组件"。

问题二:子组件没有自主权

NameEditingComponentonNameUpdated 是个无参回调

typescript 复制代码
// NameEditingComponent 里的提交逻辑
onNameUpdated(); // ⚠️ 不传参数------因为值在父组件手里

这意味着:子组件不能决定提交什么值。如果未来这个组件被复用在一个不需要点按钮提交、而是自动保存的场景,直接没法用------它的行为被父组件的接口绑死了。

问题三:接口冗余

typescript 复制代码
// V1 的接口 --- 4 个 props
interface Props {
  editingName: string;                          // 父组件的 state 镜像
  onNameUpdated: () => void;                    // 无参,因为值在父那里
  onEditingNameUpdated: (newName: string) => void;  // 每次按键都要通知父
  disabled: boolean;                            // 父组件算好了传下来
}

// V2 的接口 --- 2 个 props
interface Props {
  initialUsername: string;                      // 初始值
  onNameUpdated: (newName: string) => void;     // 提交结果
}

V1 的接口暴露了两个内部实现细节:编辑过程中的每次按键变化、按钮禁用逻辑。外部不应该知道这些。


TypeScript 在这里扮演什么角色?

你可能会问:这个项目叫 ts-demo,TypeScript 跟状态归属有什么关系?

答案是:TypeScript 的接口约束让你"设计先行"。

typescript 复制代码
// 🔑 写出接口的那一刻,设计意图就暴露了
interface Props {
  initialUsername: string;
  onNameUpdated: (newName: string) => void;
}

2 个 props 的接口一写出来,你立刻意识到:这个组件只需要"初始值"和"结果回调"。中间过程不需要暴露------TypeScript 帮你用类型划清了边界。

反过来说,如果你写出 4 个 props 的接口,TypeScript 不会报错,但你多看一眼就会发现不对劲------props 越多,说明组件越不自立。


什么时候该提升 state?什么时候该下沉?

状态归属不是绝对的。有一条判断规则:

场景 state 放哪 例子
state 只被一个组件使用 下沉到那个组件内部 editingNameNameEditComponent
state 被多个兄弟组件共享 提升到最近的公共祖先 usernameApp(被 HelloNameEditComponent 共同需要)
state 需要跨路由/全局持久化 提升到 Context/Store 用户登录态 → AuthContext

回到这个例子:

scss 复制代码
username --- 被 Hello 和 NameEditComponent 都需要 → 放 App ✅
editingName --- 只有 NameEditComponent 需要 → 放 NameEditComponent ✅
disabled --- 只有 NameEditComponent 需要 → 放 NameEditComponent ✅

V1 把所有 state 全放在 App,违反了第一条规则;V2 把编辑相关状态下沉,只把"共享状态"留在父组件。


你还学到了什么?

这个项目里还有一个值得注意的细节------子组件用自己 state 初始化时如何处理外部更新

tsx 复制代码
// ⚠️ 潜在问题:initialUsername 变化时,editingName 不会自动更新
const [editingName, setEditingName] = React.useState(props.initialUsername);

useState 的初始值只在组件首次渲染 时有效。如果父组件异步加载了新用户名,NameEditComponent 里的 editingName 不会自动更新。这是"下沉 state"需要注意的边界情况。

App.tsx(V1)中,作者其实意识到了这个问题------他用 useEffect 去加载用户名,并且同时更新 nameeditingName

tsx 复制代码
// App.tsx 的加载逻辑
React.useEffect(() => {
  loadUserName(); // 2s 后更新 name 和 editingName
}, []);

const loadUserName = () => {
  setTimeout(() => {
    setName('name from async call');
    setEditingName('name from async call'); // 🔑 两个 state 同步更新
  }, 2000);
};

这段代码在 V1 架构下是必要的------因为 editingName 在父组件手里。但在 V2 架构下,如果你希望子组件响应外部数据更新,你需要额外处理:

tsx 复制代码
// ✅ V2 的改进版:响应外部更新
const NameEditComponent: React.FC<Props> = (props) => {
  const [editingName, setEditingName] = React.useState(props.initialUsername);

  // 🔑 当外部 initialUsername 变化时,同步到内部 state
  React.useEffect(() => {
    setEditingName(props.initialUsername);
  }, [props.initialUsername]);

  // ... 其余不变
};

这不是状态归属的缺陷,而是一个提醒:state 下沉后,如果外部数据可能变化,需要明确处理同步逻辑。 这比 V1 的"全交给父组件"更需要设计意识,但也更精确。


结尾

回到开头那个问题:为什么两个版本功能一样,代码差那么多?

因为 V1 把不该自己管的 state 全揽在怀里,V2 把该谁管的还给谁。

React 组件设计的第一原则:让 state 住在离使用者最近的地方。如果它只服务一个组件,它就应该属于那个组件。

下次你设计组件时,写完 interface Props 后停下来看一眼------如果 props 超过 3 个,问自己一句:"是不是我把本该属于子组件的 state 放到父组件了?"

一个行为改变 :下次你准备 useState 的时候,先问自己 3 秒钟------"这个 state,真的需要放在当前组件吗?还是放在子组件更合适?"这个习惯比你学 10 个 React 高级模式都管用。

一个开放问题:你遇到过最臃肿的组件里有多少个 props?是怎么拆的?评论区聊聊。


相关推荐
名字还没想好☜1 小时前
React createPortal 实战:模态框逃出 overflow:hidden、事件冒泡与焦点管理
前端·javascript·react.js·ecmascript·react·createportal
breeze jiang2 小时前
React + TypeScript 编辑表单:为什么要区分 name 和 editingName
前端·typescript
gis开发之家18 小时前
《Vue3 从入门到大神40篇》Vue3 源码详解(十):diff 算法全解析 —— 为什么 Vue3 比 Vue2 更快?
javascript·算法·typescript·前端框架·vue3·vue3源码
GitLqr20 小时前
提升 Web 开发效率:我常用的 10 个 TypeScript 实战技巧
vue.js·react.js·typescript
勇往直前plus1 天前
Vue3(篇四)单页面应用到路由管理
javascript·typescript·前端框架·vue
勇往直前plus1 天前
Vue3(篇三) Element Plus
前端·javascript·typescript·vue
无人生还1 天前
从 Vue3 到 React · 快速上手系列第 6 篇:状态管理 useState 与 useReducer
前端·vue.js·react.js
退休倒计时1 天前
【每日一题】LeetCode 207. 课程表 TypeScript
算法·leetcode·typescript
张元清1 天前
React usePrevious Hook:追踪上一次的 State 和 Props(2026)
javascript·react.js