React usePrevious Hook:追踪先前状态与属性(2026)
React 提供给你每次渲染时的当前状态和属性值——但没有内置方法来询问之前的值是什么。所以大家都在复制相同的十行代码配方:在 useEffect 中将值存入 ref,然后返回 ref.current。它在演示中有效,部署到生产环境,然后有一天一个“count went up ↑”指示器开始声称没有变化——因为组件因完全无关的原因重新渲染,而 ref 悄悄覆盖了自己的历史。
来自 @reactuses/core 的 usePrevious 追踪的是先前的不同值,而不是先前的渲染值,使用 React 官方文档推荐的模式——没有 ref,没有 effect,没有漂移。整个实现只有十二行,因此本文将详细介绍经典配方中的真正 bug,为什么这个修复看起来不合法但实际上是合法的,以及可能导致无限循环的唯一陷阱。TypeScript 优先。
经典配方及其失效之处
这是存在于数千篇博客文章中,可能也存在于你一些代码库中的版本:
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
Enter fullscreen mode Exit fullscreen mode
渲染,然后 effect 触发并将当前值复制到 ref。下一次渲染读取 ref——来自上一次渲染的值。微妙之处在于最后一个短语:这个 hook 返回的是来自上一次渲染的值,而不是先前的值。只有当组件重新渲染的唯一原因是该值发生变化时,这两者才是同一件事。它永远不会保持这种状态。
看看它是如何失效的。一个带有方向指示器的计数器,加上一个不相关的状态片段:
function Counter() {
const [count, setCount] = useState(0);
const [dark, setDark] = useState(false);
const prevCount = usePrevious(count); // ref 版本
return (
<div>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setDark(!dark)}>toggle theme</button>
{prevCount !== undefined && prevCount !== count && (
<span>{count > prevCount ? "↑ went up" : "↓ went down"}</span>
)}
</div>
);
}
Enter fullscreen mode Exit fullscreen mode
点击+1:count 是 5,prevCount 是 4,指示器显示“↑ went up”。正确。现在点击toggle theme:count 没有移动,但组件重新渲染,effect 再次运行,ref 变为 5。在下一次渲染中 prevCount === count,指示器消失——组件现在认为 count 从未改变。任何父组件重新渲染、上下文更新或兄弟状态变化都会产生相同效果。你构建这个 hook 所要进行的比较正是 hook 所破坏的。
这不是假设:这是针对 react-use、ahooks 和 reactuse 本身 报告的相同故障,在实现被替换之前。
usePrevious——先前的值,而非先前的渲染
import { useState } from 'react';
import { usePrevious } from '@reactuses/core';
function Counter() {
const [count, setCount] = useState(0);
const [dark, setDark] = useState(false);
const prevCount = usePrevious(count);
return (
<div>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setDark(!dark)}>toggle theme</button>
<p>Now: {count}, before: {prevCount ?? "—"}</p>
</div>
);
}
Enter fullscreen mode Exit fullscreen mode
签名:
function usePrevious<T>(value: T): T | undefined;
Enter fullscreen mode Exit fullscreen mode
随意切换主题——prevCount 保持为 4,因为 count 实际上从未改变。在首次渲染时返回 undefined,因为还没有先前的值;相应地将你的比较类型化为 T | undefined。
实现足够简短,可以完整引用,并且不包含 ref 和 effect:
export function usePrevious<T>(value: T): T | undefined {
const [current, setCurrent] = useState<T>(value);
const [previous, setPrevious] = useState<T>();
if (value !== current) {
setPrevious(current);
setCurrent(value);
}
return previous;
}
Enter fullscreen mode Exit fullscreen mode
等等——在渲染期间 setState?
是的,这不是黑客行为:这是来自 React 官方文档的模式,在从先前渲染中存储信息下。在渲染时调用 setter 在两个条件下是合法的,这段代码满足了这两个条件:
- 这是组件自己的状态。 React 通过丢弃当前渲染的输出并立即使用新状态重新运行组件来处理渲染阶段的更新——在触及 DOM 之前,在绘制之前,在任何 effect 运行之前。永远不会看到中间帧。
-
它位于最终会安静下来的条件之后。
value !== current在变化后仅在一个渲染中为 true;重新运行然后看到value === current并直接通过。没有循环。
比较每个版本将其历史锚定到什么。ref 配方记录“上一次渲染的值是什么”——所以每次渲染都会重写历史,无论是否相关。状态版本记录“它上次改变之前的值是什么”——不相关的重新渲染遇到 value === current 并且不触及任何东西。那个单一的守卫就是整个 bug 修复。
它在 React 18 更严格的执行模型下也保持稳定,在这种模型下 effect-and-ref 版本变得更加不稳定。StrictMode 在开发中双重调用渲染函数:这里,第二次调用针对相同状态运行相同的比较并到达相同的位置——幂等。并发特性可能在提交之前丢弃渲染:被丢弃渲染的状态更新也随之丢弃,而渲染内的 ref 突变(另一个流行的“修复”)会逃脱渲染并泄漏到官方从未发生的时间线中。在渲染期间使用状态是在所有情况下都正确的变体。
陷阱:不稳定的对象
比较使用 !==——严格的引用相等性。每次渲染都向 hook 提供一个新的对象字面量,value !== current 总是为 true:
// 💥 Too many re-renders
const prev = usePrevious({ x: position.x, y: position.y });
Enter fullscreen mode Exit fullscreen mode
每次渲染都创建一个新对象,守卫触发,渲染阶段的 setState 触发重新运行,这会创建另一个新对象,React 会用“Too many re-renders.”停止整个过程。修复方法是通常的引用稳定性原则:传递原始值,或者记忆化对象以便其标识仅在其内容发生变化时才改变:
const point = useMemo(() => ({ x, y }), [x, y]);
const prevPoint = usePrevious(point); // ✅
Enter fullscreen mode Exit fullscreen mode
原始值——数字、字符串、布尔值——总是安全的,它们是你 90% 时间在追踪的内容。(如果你的真正问题是“当深度嵌套的对象实际改变时运行 effect”,那是 useDeepCompareEffect 的工作,而不是这个 hook 的工作。)
usePrevious 与 useLatest
这两者经常被混淆,因为它们都是“一个跨时间保存值的 hook”,但它们回答相反的问题:
-
usePrevious回答“这个值在改变之前是什么?”——用于渲染期间的比较:变化方向、from/to 标签、过渡检测。 -
useLatest回答“这个值现在是什么?”——从过时的闭包中,例如setInterval回调、去抖动处理程序、在挂载时注册一次的事件监听器。
如果你正在渲染差异,你需要 usePrevious。如果回调一直看到旧值,你需要 useLatest。需要其中一个绝不意味着你需要另一个。
真实用例
-
变化方向。 排序箭头、价格行情、滚动方向、“↑ 比昨天多 3 个”——任何从
value > prev渲染的内容。这是 ref 配方明显失效的情况,因为一次不相关的重新渲染就会消除方向。 -
过渡检测。 当属性跨越边界时触发逻辑,而不仅仅是当它处于某种状态时:
prevStatus === "loading" && status === "success"恰好在每个完成的请求时显示一次 toast。与useUpdateEffect配对以跳过挂载渲染。 -
From → to 动画。 数字行情和图表过渡需要两个端点;
usePrevious为你提供 tween 的起始值,而无需第二个状态片段。 -
“从 X 变为 Y”UI。 审计式表单和设置面板,显示哪些编辑待定——并排渲染
prev和value;在首次渲染时prev是undefined,你不显示任何内容。
SSR 安全性
usePrevious 是两个 useState 调用和一个比较——没有 window,没有 document,没有 effect,没有需要守护的内容。在服务器上它渲染一次并返回 undefined,客户端的首次渲染返回相同的 undefined,通过构造进行 hydration 匹配。与读取浏览器状态的 hook(cookie、localStorage、媒体查询)不同,这里没有需要设计的服务器/客户端分歧。它以无聊的方式实现 SSR 安全:通过不做任何事情。
要点
-
经典的
useRef+useEffect配方追踪先前的渲染,而不是先前的*值*——任何不相关的重新渲染都会静默重写它,这正是针对每个提供该配方的主要 hooks 库所报告的 bug。 -
usePrevious使用渲染期间的 setState——React 官方文档认可的模式:有条件的、自我终止的、对用户不可见的,并且在 StrictMode 和并发渲染下都是正确的。 -
首次渲染返回
undefined——还没有历史;针对T | undefined进行类型检查。 -
比较是按引用进行的——传递原始值或
useMemo稳定的对象,否则渲染阶段守卫会永远触发,React 会用“Too many re-renders.”阻止你。 -
闭包中的先前值与当前值是不同的问题——比较需要
usePrevious;过时的回调需要useLatest。
从 @reactuses/core 获取它,让“previous”真正意味着 previous。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.