选择 State 结构:分组、避免矛盾与消除冗余
编写一个能够正常运行的组件并不难,但编写一个在日后需求变更时不容易滋生 bug、且易于维护重构的组件,很大程度上取决于你如何组织你的 State 结构。
一个设计糟糕的 State 结构就像一团乱麻,容易出现状态不同步、相互矛盾和难以复现的奇怪缺陷。官方为我们总结了设计 State 结构的 五大黄金原则。
原则一:组合相关的 State(Group related state)
如果两个状态变量总是同时发生改变,那么把它们合并成一个单独的对象状态,通常是明智的选择:
// ❌ 分散的写法
const [x, setX] = useState(0);
const [y, setY] = useState(0);
// ✅ 更好的做法:当二者总是绑定改变时,组合成单个对象
const [position, setPosition] = useState({ x: 0, y: 0 });
原则二:避免矛盾的 State(Avoid contradictions in state)
看看下面这段发送消息的表单代码:
// ❌ 极易滋生矛盾的危险写法!
const [text, setText] = useState('');
const [isSending, setIsSending] = useState(false);
const [isSent, setIsSent] = useState(false);
如果开发人员在某个异步分支中写漏了一句 setIsSending(false),界面上就会同时出现 isSending === true 和 isSent === true 的怪象(“既在发送中,又已经发送完成”)。
正确方案:用枚举状态机替代多个布尔值
// ✅ 不可能出现矛盾的优雅写法
const [text, setText] = useState('');
const [status, setStatus] = useState('typing'); // 只能是 'typing' | 'sending' | 'sent'
在任何给定时刻,status 只能是这三个字符串中的一个,从根本上杜绝了非法状态并存的可能!
原则三:避免冗余的 State(Avoid redundant state)
这是绝大多数初学者最容易犯的错误:把能够直接计算得出的数据,也顺手存成了 State!
经典反例:全名(fullName)
// ❌ 存在严重冗余的写法
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState(''); // 冗余!
function handleFirstNameChange(e) {
setFirstName(e.target.value);
setFullName(e.target.value + ' ' + lastName); // 必须小心翼翼同步更新,极易漏改出 bug
}
黄金重构准则:
如果一个值能够在组件渲染期间,从当前的 props 或已有的其他 state 变量中直接计算出来,那么它绝对不应该被存为 State!
重构后的极致纯净代码:
// ✅ 正确做法:只存原始源头,其余在渲染时即时计算
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// fullName 是纯计算变量,绝不放进 state!
const fullName = firstName + ' ' + lastName;
每次组件因 firstName 或 lastName 改变而重新渲染时,fullName 都会自动以光速重新计算出最新结果,代码减少了一半,而且 100% 不可能出现数据脱节的 bug!
原则四:避免重复的 State(Avoid duplication in state)
当你在一个列表和当前选中的项之间建立关联时:
// ❌ 危险的重复存储!
const [items, setItems] = useState([
{ id: 0, title: '地瓜' },
{ id: 1, title: '土豆' }
]);
const [selectedItem, setSelectedItem] = useState(items[0]); // 拷贝了完整对象!
如果用户稍后在列表中修改了地瓜的名字(比如改成“烤地瓜”),selectedItem 里的名字依然是旧的“地瓜”!
正确方案:只记录选中项的 ID
// ✅ 只记录轻量标识符
const [items, setItems] = useState([
{ id: 0, title: '地瓜' },
{ id: 1, title: '土豆' }
]);
const [selectedId, setSelectedId] = useState(0);
// 在渲染时实时查表匹配对象:
const selectedItem = items.find(item => item.id === selectedId);
无论列表怎么被修改,通过 selectedId 找到的永远是列表里的同一真实数据,彻底消除了数据分叉!
原则五:避免深度嵌套的 State(Avoid deeply nested state)
如果你的状态是一个包含很多层级树状嵌套的庞大对象(比如一个多层评论回复树或包含十多层文件夹的目录),更新其中深层的一项需要层层展开拷贝,代码会极其臃肿。 推荐将深度嵌套的数据扁平化(Flat / Normalized),就像关系型数据库一样,通过对象字典和 ID 相互关联。
总结
- 合并相关:总是同步改变的属性放在同一个对象中;
- 杜绝矛盾:使用单个 status 状态替代多个可能冲突的布尔标记;
- 消灭冗余:能在渲染计算中顺手算出来的东西,坚决不要存成 state;
- 避免重复:列表选中态存 ID,而不是拷贝一整份对象;
- 扁平归一:避免深层嵌套以保持更新代码简洁健壮。