打开几乎任何 React 代码库,你都会发现同一种习惯。开发者需要追踪一个值,于是就用 useState。需要一个计数器?useState。一个临时的输入值?useState。一个用于切换模态框的布尔值?useState。不久之后,单个组件就会持有十几个独立的 hook,每个 hook 都在管理一小块数据,而这些数据可能并不需要跨渲染持久化。结果就是代码变得杂乱,产生了额外的重新渲染,且状态像零钱一样散落在组件的各个角落。

这种习惯是可以理解的。useState 是我们大多数人学习的第一个 hook,而且它确实有效。但“能用”并不等同于“合适”。将每一 piece 数据都视为响应式状态会带来问题,而这些问题只有在组件规模扩大后才会显现。

仅仅因为值会变化,并不意味着它需要状态

并不是每个随时间变化的变量都应该放在 useState 中。有些值仅仅是由于你已有的其他数据计算而来的结果。如果你仅仅因为将 firstNamelastName 拼接起来,就将用户的全名存储在状态中,那么你就拥有了两个事实来源(sources of truth)。当 firstName 因为父组件重新渲染而更新时,你的 fullName 状态会一直处于过时状态,直到你运行另一个 effect 来同步它。你并不需要一个同步 effect,你需要的是一个派生值(derived value)。

const fullName = `${firstName} ${lastName}`;

在渲染期间计算它。如果派生过程开销较大,请对其进行记忆化(memoize)。但除非用户可以独立于组成部分来编辑那个全名,否则不要给它分配独立的 useState hook。

同样的规则也适用于过滤列表。如果你在状态中同时持有 allItemsfilteredItems,你就让维护成本翻了一倍。请在渲染期间进行过滤。将源数组和过滤文本保存在状态中,然后派生出可见列表。这可以保证过滤后的列表永远不会与源数据不同步。

有些值永远不应该触发重新渲染

useState 的存在正是为了告诉 React 有某些东西发生了变化,并且 DOM 可能需要更新。如果一个值发生了变化,但 UI 的任何部分都不关心这个变化,那么 useRef 是更好的工具。

计时器和定时器(intervals)是一个经典的例子。将 setInterval 的 ID 存储在状态中,会导致每次启动或停止计时器时都触发重新渲染,尽管用户根本看不到那个 interval ID。使用 ref 可以持有该值而不通知 React。同样的逻辑也适用于追踪之前的 props、在绘制前测量 DOM 节点,或者为自定义 hook 存储最新的回调函数。问问你自己:这个值需要在屏幕上显示吗?如果答案是否定的,那么它可能不需要 useState

DOM 节点本身也应该属于 refs。虽然你可以将 DOM 元素存储在状态中,但这样做会在 ref 回调运行后触发重新渲染。在大多数情况下,你只需要该节点来进行命令式方法调用或测量,而不是为了以不同的方式渲染它。

布尔值陷阱

当每个标志位(flag)都拥有自己的 hook 时,相关的 UI 逻辑往往会变得难以控制。你会看到组件中定义了三个独立的布尔值:isLoadingisErrorisSuccess。问题在于,这三个状态并不是独立的。如果 isLoadingisSuccess 同时为 true,你的 UI 就处于一种不可能的状态,但 TypeScript 和 React 仍会允许你进行渲染。

将相关的状态进行分组可以防止这些无效组合。与其使用三个布尔值,不如追踪一个单一的状态字符串:'idle''loading''success''error'。一次只能有一个状态处于激活状态,这在类型层面消除了不可能的状态。如果数据更复杂,使用带有辨析联合类型(discriminated union)的对象会让情况变得更加清晰。当你发现自己在同一个事件处理函数中更新多个 useState 调用时,这就是一个信号,表明这些值应该归为一类。

使用 useReducer,而不是另一个 useState

当状态更新变成一场“打地鼠”游戏时,问题就来了。你在同一个函数内部调用 setA,然后调用 setB,接着根据条件调用 setC。下一个阅读这段代码的开发者必须追踪整个执行序列才能理解组件实际在做什么。

useReducer 在这里大放异彩。它取代 useState 并不是因为它更高级,而是因为逻辑需要。Reducer 集中管理了状态如何变化。你不再是在事件处理函数中散布命令式逻辑,而是发送一个意图(intention):dispatch({ type: 'submitted' })。Reducer 决定下一个状态是什么样子。这使得测试变得轻而易举,因为你的状态逻辑是一个纯函数。它也让调试变得更容易,因为每一次变化都会留下一个可追踪的 action。

你不需要为了使用 Redux 才去使用 reducer。如果你有三个或更多需要同步更新的 state 变量,或者你的下一个 state 在很大程度上取决于前一个 state,那么使用 reducer 可以极大地简化 component。

State 究竟存储在哪里

有时问题不在于你如何存储 state,而在于存储在哪里。一个常见的错误是仅仅因为 state 可能在其他地方被需要,就将其提升(hoisting)到父组件中。如果只有一个叶子 component 使用某段 state,那就把它留在那里。这就是 colocation,它可以缩小变更的影响范围。不要因为一个子组件的打开而导致父组件重新渲染。