React usePrevious Hook:追踪上一次的 State 和 Props(2026)

React 在每次渲染时都会给你 state 和 props 的当前值------但没有任何内置手段问一句:这个值之前 是多少?于是所有人都在复制同一份十行的老配方:在 useEffect 里把值塞进 ref,再返回 ref.current。demo 里它没问题,上了生产也没问题,直到某一天,一个「计数上升了 ↑」的指示器开始声称什么都没变------因为组件出于一个完全无关的原因重新渲染了一次,ref 悄悄把自己的历史覆盖掉了。

@reactuses/coreusePrevious 追踪的是上一个不同的值 ,而不是上一次渲染时的值,用的正是 React 官方文档推荐的模式------没有 ref,没有 effect,不会漂移。整个实现只有十二行,所以这篇文章会走一遍经典配方里的真实 bug、那个看起来「违法」实则合规的修复方式,以及唯一一个可能让它陷入无限循环的坑。TypeScript 优先。

经典配方,以及它在哪里散架

下面这个版本活在上千篇博客里,大概率也活在你的某个代码库里:

tsx 复制代码
function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T>();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

渲染完成后 effect 触发,把当前值拷进 ref。下一次渲染读这个 ref------拿到的是一次渲染之前的值。微妙之处就在最后这句话里:这个 hook 返回的是上一次渲染 时的值,不是上一个 。这两者只有在一种情况下才相等:组件重新渲染的唯一原因就是这个值变了。而这种情况从来维持不了多久。

看它怎么坏掉。一个带方向指示器的计数器,外加一个无关的 state:

tsx 复制代码
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>
  );
}

+1count 是 5,prevCount 是 4,指示器显示「↑ 上升」。正确。现在点切换主题count 一动没动,但组件重新渲染了,effect 又跑了一次,ref 变成了 5。下一次渲染时 prevCount === count,指示器消失了------组件现在坚信计数从来没变过。任何父组件的 re-render、context 更新、兄弟 state 的变化都会造成同样的结果。你为「比较」而引入的这个 hook,恰恰把「比较」本身弄坏了。

这不是假想的边界情况:react-useahooks、还有 reactuse 自己,都被报过一模一样的问题,直到实现被替换掉。

usePrevious------上一个 ,不是上一次渲染

tsx 复制代码
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>
  );
}

签名:

ts 复制代码
function usePrevious<T>(value: T): T | undefined;

主题随便切多少次------prevCount 始终是 4,因为计数确实没变过。首次渲染时它返回 undefined,因为此时还不存在「之前的值」;写比较逻辑时按 T | undefined 来标注类型。

实现短到可以全文引用,而且里面没有任何 ref、任何 effect:

ts 复制代码
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永远为真:

tsx 复制代码
// 💥 Too many re-renders
const prev = usePrevious({ x: position.x, y: position.y });

每次渲染创建一个新对象,守卫条件触发,render 期间的 setState 引发重跑,重跑又创建另一个新对象,最后 React 用「Too many re-renders」把整件事拦停。修复方式就是常规的引用稳定性纪律:传原始值,或者用 memo 让对象只在内容变化时才换身份:

tsx 复制代码
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」的界面。 展示待提交改动的审计式表单和设置面板------把 prevvalue 并排渲染;首次渲染时 prevundefined,什么都不显示即可。

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。

相关推荐
Cobyte1 小时前
手写 Rollup 插件实现解析 Vue 文件
前端·javascript·vue.js
安冬的码畜日常2 小时前
【工欲善其事】深入理解 Node.js 带并发上限的异步任务批量执行逻辑
javascript·设计模式·node.js·ai编程·异步编程·并发执行
meilindehuzi_a3 小时前
Vue3组件样式隔离与组件通信实战:从 scoped、props 校验到记事本应用
前端·javascript·vue.js
LaughingZhu3 小时前
Product Hunt 每日热榜 | 2026-07-28
前端·神经网络·react.js·搜索引擎·前端框架
Highcharts.js11 小时前
教程:基于 React + Highcharts 构建一个单页应用程序、按需数据拉取与图表渲染
开发语言·前端·数据结构·react.js·前端框架·highcharts·页面应用
To_OC11 小时前
我被 useState 坑了两次之后,终于把它的脾气摸透了
前端·javascript·react.js
Luu.ྀ12 小时前
React详细笔记
javascript·笔记·react.js
浮生望12 小时前
React useState 深度解析:异步更新、闭包陷阱与惰性初始化的底层逻辑
react.js
Patrick_Wilson14 小时前
从 React 到 Flutter:写给前端的一张跨端知识地图
前端·flutter·react.js