2026年7月29日
React usePrevious Hook:追踪上一次的 State 和 Props(2026)
React 在每次渲染时都会给你 state 和 props 的当前值——但没有任何内置手段问一句:这个值之前是多少?于是所有人都在复制同一份十行的老配方:在 useEffect 里把值塞进 ref,再返回 ref.current。demo 里它没问题,上了生产也没问题,直到某一天,一个「计数上升了 ↑」的指示器开始声称什么都没变——因为组件出于一个完全无关的原因重新渲染了一次,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;
}
渲染完成后 effect 触发,把当前值拷进 ref。下一次渲染读这个 ref——拿到的是一次渲染之前的值。微妙之处就在最后这句话里:这个 hook 返回的是上一次渲染时的值,不是上一个值。这两者只有在一种情况下才相等:组件重新渲染的唯一原因就是这个值变了。而这种情况从来维持不了多久。
看它怎么坏掉。一个带方向指示器的计数器,外加一个无关的 state:
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)}>切换主题</button>
{prevCount !== undefined && prevCount !== count && (
<span>{count > prevCount ? "↑ 上升" : "↓ 下降"}</span>
)}
</div>
);
}
点 +1:count 是 5,prevCount 是 4,指示器显示「↑ 上升」。正确。现在点切换主题:count 一动没动,但组件重新渲染了,effect 又跑了一次,ref 变成了 5。下一次渲染时 prevCount === count,指示器消失了——组件现在坚信计数从来没变过。任何父组件的 re-render、context 更新、兄弟 state 的变化都会造成同样的结果。你为「比较」而引入的这个 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)}>切换主题</button>
<p>现在:{count},之前:{prevCount ?? "—"}</p>
</div>
);
}
签名:
function usePrevious<T>(value: T): T | undefined;
主题随便切多少次——prevCount 始终是 4,因为计数确实没变过。首次渲染时它返回 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;
}
等等——render 期间 setState?
对,而且这不是黑魔法:它就是 React 官方文档里的模式,出处是 storing information from previous renders。在渲染期间调用 setter 在两个条件下是合法的,这段代码两条都满足:
- 改的是组件自己的 state。 React 处理 render 期间的更新的方式是:丢弃当前这次渲染的输出,立刻带着新 state 重新执行组件——在碰 DOM 之前、在绘制之前、在任何 effect 运行之前。用户永远看不到中间帧。
- 它被一个终会安静下来的条件守着。
value !== current只在值变化后的那一次渲染里为真;重跑时value === current,直接落空。不会循环。
对比一下两个版本各自锚定的「历史」。ref 配方记录的是「上一次渲染时值是多少」——所以每一次渲染都会改写历史,不管相关不相关。state 版本记录的是「值上一次变化之前是多少」——无关的 re-render 撞上 value === current,什么都不碰。这一个判断条件就是整个 bug 修复。
在 React 18 更严格的执行模型下它也站得住,而 effect + ref 的版本反而更摇晃。StrictMode 在开发环境会把渲染函数执行两遍:这里第二遍跑的是同样的比较、对着同样的 state、落到同样的结果——幂等。并发特性可能在提交前把一次渲染整个扔掉:被丢弃的渲染里的 state 更新会跟着一起被丢弃,而在渲染期间改 ref(另一种流行的「修法」)会逃逸出这次渲染,泄漏到一条官方口径里从未发生过的时间线上。render 期间 setState 是唯一在所有这些场景下都正确的变体。
那个坑:不稳定的对象
比较用的是 !==——严格引用相等。每次渲染都喂给 hook 一个新的对象字面量,value !== current 就永远为真:
// 💥 Too many re-renders
const prev = usePrevious({ x: position.x, y: position.y });
每次渲染创建一个新对象,守卫条件触发,render 期间的 setState 引发重跑,重跑又创建另一个新对象,最后 React 用「Too many re-renders」把整件事拦停。修复方式就是常规的引用稳定性纪律:传原始值,或者用 memo 让对象只在内容变化时才换身份:
const point = useMemo(() => ({ x, y }), [x, y]);
const prevPoint = usePrevious(point); // ✅
原始值——数字、字符串、布尔——永远安全,而它们覆盖了你九成的使用场景。(如果你真正的问题是「当一个深层嵌套对象真的变化时才执行 effect」,那是 useDeepCompareEffect 的活,不归这个 hook 管。)
usePrevious vs useLatest
这两个经常被搞混,因为它们都是「跨时间持有一个值的 hook」,但它们回答的是相反的问题:
usePrevious回答的是**「这个值在变化之前是多少?」**——用于渲染期间的比较:变化方向、from/to 标签、状态迁移检测。useLatest回答的是在一个过期闭包里**「这个值现在是多少?」**——setInterval回调、防抖过的处理函数、挂载时注册一次的事件监听器。
要渲染一个 diff,用 usePrevious;某个回调总是看到旧值,用 useLatest。需要其中一个,从来不意味着你需要另一个。
真实使用场景
- 变化方向。 排序箭头、价格跳动、滚动方向、「↑ 比昨天多 3 个」——一切由
value > prev渲染出来的东西。这正是 ref 配方肉眼可见坏掉的场景,因为一次无关的 re-render 就能抹掉方向。 - 状态迁移检测。 在 prop 跨过边界时触发逻辑,而不是它处于某状态时:
prevStatus === "loading" && status === "success"让 toast 在每个请求完成时恰好弹一次。搭配useUpdateEffect还能跳过挂载时的首次渲染。 - From → to 动画。 数字滚动和图表过渡需要两个端点;
usePrevious直接把补间的起始值递给你,不用再造第二份 state。 - 「从 X 改为 Y」的界面。 展示待提交改动的审计式表单和设置面板——把
prev和value并排渲染;首次渲染时prev是undefined,什么都不显示即可。
SSR 安全性
usePrevious 就是两个 useState 加一次比较——没有 window,没有 document,没有 effect,没有任何需要守卫的东西。服务端渲染一次,返回 undefined;客户端首次渲染返回同样的 undefined,hydration 天然一致。不像那些读浏览器状态的 hook(cookie、localStorage、媒体查询),这里不存在需要专门设计的服务端/客户端分歧。它的 SSR 安全是最无聊的那种:因为它什么都不做。
要点回顾
- 经典的
useRef+useEffect配方追踪的是上一次渲染,不是上一个值——任何无关的 re-render 都会悄悄改写它,这正是每个曾经内置这份配方的主流 hooks 库都收到过的 bug 报告。 usePrevious用的是 render 期间 setState——React 官方文档认可的模式:有条件、自行终止、对用户不可见,且在 StrictMode 和并发渲染下都正确。- 首次渲染返回
undefined——此时还没有历史;按T | undefined标注类型。 - 比较按引用进行——传原始值或用
useMemo稳定过的对象,否则 render 期间的守卫会永远触发,React 会用「Too many re-renders」拦停你。 - 「上一个值」和「闭包里的当前值」是两个不同的问题——做比较用
usePrevious;救过期回调用useLatest。
从 @reactuses/core 拿来用,让「previous」真的是 previous。