存储原因,推导结果:设计极简的 React 状态
学会识别多余的 React 状态,用派生值和状态联合体替代由 Effect 驱动的同步链与布尔标志,并确定状态应存储在何处。
大多数 React 组件并不会因为一个错误的决策就变得难以修改。它们会一次通过看似合理的 useState 调用逐渐陷入这种状态,直到相同的信息被存储在三个地方,而没人能确定哪个版本是权威的。本指南将通过一个实际的产品列表案例来展示这种问题,然后说明如何确定组件应该记住什么、在每次渲染时需要计算什么,以及剩余的每个状态应存储在哪里。读完之后,你将拥有一个具体的检查清单,用于在状态问题演变成同步错误之前对其进行优化。
简单的产品列表是如何积累状态的
想象一个列出产品的内部管理界面。用户可以输入名称进行搜索,将列表限制在某一类别中,按价格排序,选择单个产品,并查看还有多少匹配结果。第一个版本仅保存用户输入的内容和所做选择:
function Products({ products }) {
const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");
// ...
}
随后出现了功能需求。由于表格需要显示匹配的产品,于是有人添加了一个用于存储过滤后列表的状态变量:
const [filteredProducts, setFilteredProducts] = useState(products);
表格上方有一个计数器显示匹配的产品数量,这个数字也有自己的状态变量:
const [resultCount, setResultCount] = useState(products.length);
当没有匹配结果时,页面应显示空状态提示信息,因此也添加了相应的标志:
const [hasResults, setHasResults] = useState(true);
接着是排序功能,需要同时处理所选的排序键以及排序后的列表副本:
const [sortBy, setSortBy] = useState("name");
const [sortedProducts, setSortedProducts] = useState(products);
每次的改动都很小,易于在审查中获得通过。然而几周后,错误报告开始出现。切换分类时有时会显示正确的行但计数却有误。清空搜索框时,会短暂出现“无结果”的提示信息。当新的API响应带来新的products数据时,表格仍会显示过时的筛选列表,直到用户进行操作为止。
该组件虽然包含大量状态信息,却再也无法回答那个最重要的问题:这些值中哪个才是真实的?
useState并非问题所在。真正的麻烦在于,当一个组件存储了多个本可从更少的基础数据中计算得出的信息版本时。每多存储一个值,就需要同步维护的内容就多一分,而正是这种同步需求使得简单组件变得脆弱。
每个存储的值都是出错的另一种可能
组件需要状态,是因为有些信息必须在多次渲染之间保持不变:输入框中的文本、当前激活的标签页、模态框是否打开、用户选择了哪一行。这些都是使用状态的合适场景。
错误在于将“这个内容要显示在屏幕上”误认为是“这个内容必须被存储下来”。再看看那些将所有值都存储在状态中的产品页面吧:
const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");
const [filteredProducts, setFilteredProducts] = useState(products);
const [resultCount, setResultCount] = useState(products.length);
const [hasResults, setHasResults] = useState(true);
每个变量都有合理的名称,但它们彼此之间并非独立。过滤后的列表是由三个输入参数决定的:
products + search + category
计数值取决于过滤后的列表:
filteredProducts.length
而空状态标志则由计数值决定:
resultCount > 0
少数几个真实事实被扩展成了多个存储后的结果。这就导致了界面根本无法显示的一些组合情况,比如这种:
filteredProducts = []
resultCount = 4
hasResults = true
React会愉快地保存这些值。既然你声明了三个独立的状态,React就会将它们视为三个独立的整体。保持它们在逻辑上的一致性完全是应用程序的责任,任何操作这些状态的事件处理程序都必须记住其他状态的值。
React 文档正是出于这个原因建议避免冗余和重复的状态:如果在渲染时某个值可以从属性或其他状态中计算得出,单独存储它只会为这些值的不一致提供新的可能。
关键点并非“减少调用 useState 的次数”,而是要改变你对状态的认知:
记住那些组件无法通过其他方式获取的事实,然后基于这些事实计算出所有相关值。
将输入内容保存在状态中,其余部分再计算
产品页面从来不需要记住 resultCount,它需要记住的是用户输入到搜索框中的内容以及他们选择的类别。这些都是人为做出的选择,产品数据中没有任何信息能够重新生成这些内容,其余的一切都可以由此推导出来。
function Products({ products }) {
const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");
const filteredProducts = products.filter((product) => {
const matchesSearch = product.name
.toLowerCase()
.includes(search.toLowerCase());
const matchesCategory =
category === "all" || product.category === category;
return matchesSearch && matchesCategory;
});
const resultCount = filteredProducts.length;
const hasResults = resultCount > 0;
// ...
}
注意那些已经消失的内容。无需再更新resultCount,hasResults也没有设置器,而且不再存在某个处理程序更新过滤后的列表却忘记更新计数的情况。每次渲染都会根据当前的输入重新计算输出结果。新的搜索字符串会生成新的列表,新的分类也会生成新的列表;如果父组件传入不同的products数组,计算就会直接使用该数组。
该组件现在可写的变量更少了,这比单纯减少代码行数更有意义。派生值仍可能包含逻辑错误,但由于不会因为某个处理程序忘记更新而导致数据过时,因此消除了组件之前可能出现的各种状态问题。
React 文档用由名和姓组合而成的 fullName 来说明同一个观点:如果在渲染时就能计算出该值,那么额外的状态变量除了可能带来不一致性之外没有任何作用。
在代码审查时可以进行的快速测试:
If I deleted this state variable,
could I reconstruct its value exactly
from current props and other state?
如果答案是肯定的,首先应将其转化为简单的计算。状态的存在是为了存储信息,而非缓存组件在处理过程中产生的每一个中间结果。
当 useEffect 变成同步流程时
面对过时的派生状态,常见的做法是使用 useEffect 自动更新副本。这样一来,产品组件就会变成类似这样的结构:
const [filteredProducts, setFilteredProducts] = useState(products);
useEffect(() => {
const nextProducts = products.filter((product) => {
const matchesSearch = product.name
.toLowerCase()
.includes(search.toLowerCase());
const matchesCategory =
category === "all" || product.category === category;
return matchesSearch && matchesCategory;
});
setFilteredProducts(nextProducts);
}, [products, search, category]);
接着,第二个 Effect 会确保计数与列表保持一致:
useEffect(() => {
setResultCount(filteredProducts.length);
}, [filteredProducts]);
或许还有第三个 Effect 用于控制空状态标志:
useEffect(() => {
setHasResults(resultCount > 0);
}, [resultCount]);
它们共同构成一个小型内部处理流程:
products/search/category
↓
filteredProducts
↓
resultCount
↓
hasResults
这些步骤都不会与 React 之外的任何内容交互。它们仅对 React 已经持有的值进行转换,而这一区别正是问题的核心。React 的文档将 Effects 视为一种应急手段,用于让组件与 React 无法控制的事物保持同步,比如浏览器 API、套接字或非 React 组件。当某个 Effect 仅仅是为了响应另一个状态变化而设置组件的某一部分状态时,目前的建议是思考那另一部分状态是否真的有必要存在。
还有一种容易被忽视的运行时开销。每个 Effect 都在 React 完成渲染之后才会执行,因此链条中的每一个环节都会触发另一次使用部分更新后的值的渲染。这正是开头场景中短暂出现“无结果”提示的原因:在某一次渲染时,新的列表已经存在,但标志仍然显示旧的计数。
计算版本则完全没有这样的链条:
const filteredProducts = filterProducts(
products,
search,
category
);
const resultCount = filteredProducts.length;
const hasResults = resultCount > 0;
这不仅仅是更整洁的语法,它还改变了我们的思考方式。使用存储的派生状态时,我们需要追踪每个值最后被修改的时间、相关的 Effect 是否已经执行、其依赖数组是否完整,以及是否有其他更新仍在其后排队等待处理。而通过计算方式,则只需考虑输入和输出,对于纯转换操作来说,这种模型更易于维护。如果你的代码库中已经存在这类 Effect,停止使用 useEffect 同步状态一文中的逐步重构指南会介绍如何安全地移除它们。
用单一状态替代布尔标志
重复的值是冗余状态的一种表现形式,另一种情况则是将同一个概念分散在多个独立的布尔值中。表单提交就是典型的例子:
const [isIdle, setIsIdle] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);
流程应当严格处于四个阶段中的某一个:
idle
submitting
success
error
然而,四个布尔值可以表示十六种组合,其中很多都是无意义的。表单可能会同时声称正在提交且已经成功:
isSubmitting = true
isSuccess = true
或者它会同时报告成功与失败:
isSuccess = true
isError = true
又或者所有标志都为false,这不符合任何一种阶段状态。用户界面通常不会刻意生成这些组合,但由于数据结构允许存在这类情况,只要某个处理函数中遗漏了设置操作,就可能导致这种情况出现。React关于状态设计的指导明确建议避免此类矛盾,并减少那些会导致不可能的界面状态的变量。
单个状态值能够更精确地描述该概念。在 TypeScript 中,字符串字面量联合类型还能让编译器拒绝拼写错误和未知状态:
type Status =
| "idle"
| "submitting"
| "success"
| "error";
const [status, setStatus] = useState<Status>("idle");
那些便捷的布尔值依然可用,只不过现在是作为派生值存在的:
const isSubmitting = status === "submitting";
const isSuccess = status === "success";
const isError = status === "error";
两者的差异看似微小,但实际上很关键。第一种方式要求代码确保四个事实保持一致;而第二种方式则只存储一个事实,并读取它的四种不同表现形式。
随着组件规模的增大,这种差异带来的好处也会更加明显。例如结账流程可能会经历这些阶段:
editing
validating
submitting
confirmed
failed
文件导入功能也可能经历这些阶段:
idle
uploading
processing
completed
failed
当某个组件的不同模式之间存在互斥关系时,应将该互斥规则纳入状态模型中,而非要求每个处理函数都必须遵守。因为正是你决定了程序能够表示哪些状态,这一决策应当与标记结构一样受到重视。不过需要注意一点:如果某个阶段包含特定数据,比如仅在failed阶段存在的错误信息,使用带区分度的对象联合类型可以将这些数据绑定到对应的阶段,而无需再添加额外的独立变量。
减少变量数量并不等同于使用一个大型对象
一旦团队听到“减少状态”这样的建议,他们往往会犯一个过度的错误,那就是把所有内容都塞进一个对象里:
const [state, setState] = useState({
search: "",
category: "all",
selectedProductId: null,
sidebarOpen: false,
page: 1,
});
这并非默认情况下的改进。单独调用useState并不会带来任何实际成本,因此减少其调用次数并非目标。真正的目标是清晰地表示独立的信息,避免重复存储相同的值。
search和category会按照各自的节奏变化,而sidebarOpen与它们两者都无关。将它们作为独立的变量处理,可以让每次更新的位置一目了然:
const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");
const [selectedProductId, setSelectedProductId] =
useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
React文档也持相同观点。始终一起变化的值可以考虑合并,而冗余、矛盾、重复或深度嵌套的数据则应予以简化。将不相关的值合并在一起还有实际弊端:每次更新时都必须复制之前的对象,若忘记操作,则其他字段会被悄悄覆盖。
因此,关键问题不在于一组值是否能够放入一个对象中——几乎任何数据都可以。应该这样问:
这些值是否构成一个逻辑连贯的状态整体,其状态变化也相互关联?
如果是的话,将它们归组可以让代码更清晰。如果不是,创建一个合并后的对象只会让人难以分辨哪些更新影响了什么内容。简化状态的意义在于消除重复存储的信息,而非尽可能少地使用 Hooks。
在不过度影响性能的前提下推导值
将过滤后的列表移出状态通常会引发一个质疑:这样过滤器不是会在每次渲染时都运行吗?确实如此,但对于大多数常见的数据转换而言,这正是合适的权衡。对规模适中的数组进行过滤成本很低,而且在内联计算的情况下不会增加组件复杂性,也不会带来明显性能损耗。
如果性能分析显示某个转换确实非常耗资源,比如需要对大型列表进行过滤和排序,可以使用 useMemo 来缓存结果:
const filteredProducts = useMemo(() => {
return products
.filter((product) => {
const matchesSearch = product.name
.toLowerCase()
.includes(search.toLowerCase());
const matchesCategory =
category === "all" ||
product.category === category;
return matchesSearch && matchesCategory;
})
.sort(compareProducts);
}, [products, search, category, sortBy]);
需要注意 useMemo 并不会改变什么。filteredProducts 再次变成了不可写状态,它仍然是其输入参数的纯函数;记忆化机制仅决定在特定渲染时 React 是否可以复用之前的结果而非重新计算。这样就能将正确性与优化分开处理。React 的文档明确将 useMemo 视为一种性能优化手段,并警告不要依赖它来保证行为的正确性,因为 React 可能会丢弃缓存过的值。
由此产生的操作顺序为:
First make the state model correct.
Then measure.
Then optimize expensive calculations if necessary.
将状态用作手动实现的缓存会改变这种顺序。在尚未证明相关计算速度较慢之前,它就增加了同步处理的复杂性。此外还需检查被缓存的计算结果是否在其依赖数组中列出了所有输入项;在上面的示例中,sortBy被列入其中是因为排序比较器需要依赖它。
将每个状态放在需要共享决策的地方
即便某些状态确实有必要存在,但如果放置在了错误的组件中也会引发问题。假设每行产品都独立记录自己的选择状态:
function ProductRow({ product }) {
const [selected, setSelected] = useState(false);
// ...
}
只要每行都能独立切换,这种方式就能正常工作。但现在需求变了:一次只能选择一个产品。这样一来,多个兄弟组件各自都持有本应唯一共享的信息——即当前选中的产品是什么。当这个信息对多个兄弟组件都很重要时,就应该由它们的父组件来管理:
function ProductTable({ products }) {
const [selectedProductId, setSelectedProductId] =
useState<string | null>(null);
return products.map((product) => (
<ProductRow
key={product.id}
product={product}
selected={product.id === selectedProductId}
onSelect={() => setSelectedProductId(product.id)}
/>
));
}
行不再存储任何选择状态。它们只会接收一个布尔值和一个回调函数,而且存在唯一的真实数据源:
selectedProductId
React文档将此方法描述为为每个独立的状态项指定一个唯一的负责组件。当多个组件需要围绕相同的信息进行协调时,将其提升到最近的共享父组件中,可以避免各处的副本出现差异。
以上内容都并非主张要将所有数据都放到应用的根层级。应当明确的是,其他不需要共享的数据应保持本地化。工具提示的显示状态与认证数据放在一起毫无意义,而半填写的表单字段也几乎不足以成为使用全局存储的理由。当状态的所有者与相关决策的传播范围保持一致时,状态最容易管理。如果将状态放置得过低,各个组件就会重复存储相同的数据;如果放置得过高,应用中远离该状态的部件就会因与它们无关的变更而重新渲染。找到这个平衡点正是良好状态设计的关键,《重新思考 React 状态:数据应真正存储在哪里》一文更深入地探讨了本地、共享、服务器及 URL 存储方案。
Reducer 用于管理状态转换,而非模型本身
当一个组件需要处理多个设置函数时,通常下一步会采用 useReducer,而这往往是个不错的选择。不必再使用那种需要依次执行多个协调操作的处理器:
setStatus("submitting");
setError(null);
setLastAttempt(Date.now());
而是可以将所发生的情况描述为单个事件:
dispatch({ type: "submitted" });
Reducer能将所有状态变化集中到一处处理,当多个相关联的值同时发生变化时这一点尤为有用。但它无法消除冗余数据。这种初始状态依然存在问题:
const initialState = {
search: "",
products: [],
filteredProducts: [],
resultCount: 0,
hasResults: true,
};
将重复的值放入Reducer中并不能解决同步问题,只是把同步逻辑转移到了Reducer里而已。虽然目前每个操作都能正确更新所有副本,但模型仍然允许存储同一信息的多个版本,后续添加的操作可能会遗漏其中某个版本。
更好的 reducer 只保留输入数据:
const initialState = {
search: "",
category: "all",
sortBy: "name",
};
在渲染时,可见的产品列表会根据 reducer 状态以及当前的 products 数据生成。当状态转换变得复杂时,useReducer 是一个很好的工具,但它并不能替代对组件究竟需要记住什么这一基本问题的思考。先回答这个问题,再选择合适的工具来管理状态。
审查组件状态的检查清单
useState让状态管理几乎毫无阻力,但这种便捷性掩盖了其带来的架构成本。每一个新变量都意味着又一个可能独立变化的值。如果它与现有值重复,就需要制定规则来确保两个版本保持一致。一个重复值尚可管理,但五个重复值就会引发一系列问题:效应与依赖关系混乱、触发其他设置器的设置函数、重置代码、过时的读取数据、冲突的标志,以及只有在特定点击顺序下才会出现的错误。解决之道往往不是采用更智能的同步机制,通常情况下根本就不应该存在同步机制。
当组件的状态不断增长时,应逐一检查每个存储的值并询问:
- 它是否代表了用户或系统做出的决策?
- 该组件是否需要在多次渲染之间保留这些值?
这些问题能提供的信息远比仅仅统计 Hook 的数量要多。一个拥有八个独立且必要的状态值的组件完全可以设计得很好,而如果三个变量中有两个是第三个的副本或衍生结果,那这个组件的状态就已经过多。
关键要点
- 存储诸如用户输入和选择之类的原因;在渲染过程中推导出过滤后的列表、计数值及标志等结果。
- 如果某个 Effect 只是从其他状态中设置状态,那就说明第二个值应当是通过计算得到的。
- 将互斥的模式视为一个状态值,从而避免表示出不可能的组合。
- 仅在数值同时变化时才将它们归类;单个大型对象本身并非目标。
- 在完成测量后使用
useMemo,并记住它只是缓存而非真实数据来源。 - 为共享的事实在最低层的公共父节点处指定唯一的负责人,将纯粹本地化的 UI 状态保留在本地。
- 当某个组件变得难以修改时,在添加新的设置函数之前,先思考每个状态变量代表什么现实世界中的事实。最容易保持同步的状态,往往是那些从未被存储过的状态。
相关阅读
- React Query与Redux:重新思考大型应用中的服务器状态 — 了解为何某款生产级聊天应用选择使用TanStack Query而非Redux来管理服务器数据,以及Redux在现代React架构中仍能发挥的作用。
- 重新思考React状态:数据应存储在何处 — 本文阐述了通过将状态置于URL、DOM或派生值中,而非过度使用useState,来减少React应用中的错误的方法。