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:两种架构的数据流对比
一张图就看清楚:V1 是 4 条线全从父组件出去,V2 是父和子各管各的,只有 2 条线做交接。
为什么 V1 的设计有问题?
三个具体问题,每个都值得认真看。
问题一:父组件知道太多
typescript
// ⚠️ 父组件在计算 disabled------这件事跟父组件有什么关系?
disabled={editingName === '' || editingName === name}
"按钮能不能点"这个判断,本质上是编辑组件自己的事。父组件只需要知道"用户提交了新名字"就够了。父组件不应该知道编辑过程的中间状态。
这种"父组件知道太多"的设计,随着功能增加会雪崩------下次你要加"输入长度限制"、"特殊字符校验"、"实时保存",所有逻辑全堆在父组件,它就成了"上帝组件"。
问题二:子组件没有自主权
NameEditingComponent 的 onNameUpdated 是个无参回调:
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 只被一个组件使用 | 下沉到那个组件内部 | editingName → NameEditComponent |
| state 被多个兄弟组件共享 | 提升到最近的公共祖先 | username → App(被 Hello 和 NameEditComponent 共同需要) |
| 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 去加载用户名,并且同时更新 name 和 editingName:
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?是怎么拆的?评论区聊聊。