这是一个会对用户撒谎的自动保存按钮:
tsx
function Editor({ docId }: { docId: string }) {
const [text, setText] = useState("");
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "dirty">("idle");
async function save() {
setStatus("saving");
await api.save(docId, text);
setStatus("saved"); // ⚠️ 但用户在 await 期间还在打字......
}
return (
<>
<textarea value={text} onChange={e => setText(e.target.value)} />
<button onClick={save}>Save</button> <em>{status}</em>
</>
);
}
请求耗时 800 毫秒。请求在飞的时候用户又敲了三个词。Promise 兑现,status 翻成 "saved",而那三个新词根本没被保存。在 save 里面,text 是按钮被点击那一刻的值------JavaScript 闭包捕获了那次渲染的值,之后再多的重新渲染也改不了它。要在 await 之后判断到底是 "saved" 还是 "dirty",你需要知道 text 现在是什么,而闭包告诉不了你。
这就是过期闭包 (stale closure),任何一个活得比创建它的那次渲染更久的回调都会遇到:setTimeout、setInterval、await 之后的代码、只注册一次的事件监听器、IntersectionObserver 和 ResizeObserver 的回调、WebSocket 的 onmessage,以及每一个在构造时接收回调的第三方 SDK。React 官方 FAQ 对*"为什么我在函数里看到的是过期的 props 或 state?"*给出的答案,就是一个永远持有最新值的 ref。@reactuses/core 里的 useLatest 就是把这个 ref 打包好:五行代码,不触发重新渲染,没有依赖数组。本文会讲清楚它是什么、为什么实现里是在 layout effect 而不是渲染期间写 ref、它跟 useRef、useEvent 和 React 的 useEffectEvent 是什么关系、它为哪些模式而生,以及你必须遵守的唯一一条规矩。
快速上手
bash
npm install @reactuses/core
tsx
import { useLatest } from "@reactuses/core";
import { useState } from "react";
function Editor({ docId }: { docId: string }) {
const [text, setText] = useState("");
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "dirty">("idle");
const latestText = useLatest(text);
async function save() {
const snapshot = text; // 闭包:我们要发出去的是什么
setStatus("saving");
await api.save(docId, snapshot);
// ref:用户现在手上的是什么
setStatus(latestText.current === snapshot ? "saved" : "dirty");
}
// ...
}
useLatest(value) 返回一个 MutableRefObject<T>,它的 .current 永远是最近一次渲染的 value。ref 对象本身的身份从不改变,所以在任何地方闭包捕获它都是安全的------定时器、Promise、订阅------等回调最终执行时再读就行。注意修好的版本同时 用了闭包和 ref:闭包是点击那一刻的值(回答"我们发了什么?"是对的),ref 是 Promise 兑现那一刻的值(回答"它还是最新的吗?"是对的)。过期闭包不是 JavaScript 的 bug;只有当你想要的是现在 、拿到的却是那时,它才是 bug。
useLatest 到底是什么
@reactuses/core 里的完整实现:
tsx
import { useRef } from "react";
import { useIsomorphicLayoutEffect } from "@reactuses/core";
function useLatest<T>(value: T) {
const ref = useRef(value);
useIsomorphicLayoutEffect(() => {
ref.current = value;
}, [value]);
return ref;
}
就这些。一个只创建一次的 ref,每当 value 变化就覆写它的 .current。有两个细节值得弄明白,因为手写版本的差异正在这里。
为什么用 layout effect,而不是在渲染函数体里直接 ref.current = value? 几个流行实现(react-use、ahooks)就是在渲染期间赋值的,绝大多数时候也能用。但 React 的规则要求渲染必须是纯的------渲染期间不读也不写 ref.current------因为在并发渲染下,一次渲染可以被开始、暂停,然后在从未提交的情况下直接丢弃 。在一次被丢弃的渲染里写下的 ref,现在持有的是一个没有任何已提交 UI 显示过的值。在 useLayoutEffect 里写,意味着 ref 每次已提交 的渲染恰好更新一次,在 DOM 更新之后、浏览器绘制之前同步完成。这跟 React useEvent RFC 用的是同一个技巧,也是为什么 @reactuses/core 的 useEvent、useInterval、useTimeoutFn 和另外十几个 hook 都建在 useLatest 之上,而不是裸的渲染期赋值。
为什么不用普通的 useEffect? 时序。passive effect 在绘制之后运行,而同一次提交里 React 先跑子组件的 effect 再跑父组件的,先跑靠前的 hook 再跑靠后的。如果同一次提交里有另一个 effect 在更新用的 effect 之前读了这个 ref,它看到的就是上一次渲染的值。layout effect 跑在所有 passive effect 之前,所以等到任何 useEffect、事件处理器、定时器或 Promise 回调触发时,ref.current 已经是最新的了。(useIsomorphicLayoutEffect 在浏览器里就是 useLayoutEffect,在服务端就是 useEffect,所以没有 SSR 警告。)
你需要内化的结论是:.current 反映的是最近一次已提交的渲染,它是给回调读的,不是给渲染读的。 在第 N+1 次更新的渲染期间,ref.current 还持有第 N 次的值------这没问题,因为在渲染里你本来就该直接读 value。如果你发现自己在 JSX 里写 {latest.current},你要的其实是普通的 state。
useLatest vs useRef vs useState
这三个经常被搞混,因为它们都"存一个值"。关键问题是谁 需要这个值、什么时候需要。
| 你需要...... | 用 |
|---|---|
| 渲染这个值,并在它变化时重新渲染 | useState |
| 跨渲染保存一个不由 prop/state 派生的可变值(定时器 id、DOM 节点、计数器) | useRef |
| 在一个活得比渲染更久的回调里读到最新的 prop 或 state | useLatest |
useLatest 就是 useRef 加上一个"帮我保持同步"的 effect。如果你写过这个:
tsx
const textRef = useRef(text);
useEffect(() => { textRef.current = text; }, [text]);
......那就是 useLatest(text),只差上面说的 layout effect 时序细节。它也比另一种 常见的变通写法------在 setter 旁边把 state 复制进 ref(setText(v); textRef.current = v;)------诚实得多,后者一旦有别的东西更新了 text(一个重置按钮、一个 prop、一个表单库),就会悄无声息地坏掉。
useLatest vs useEvent vs useEffectEvent
再看几个近邻。三者都是为了对付过期闭包而存在的;区别在于它们包的是什么。
useLatest(value)包一个值 ,给你一个 ref。你在已有的任何回调里读.current。useEvent(fn)包一个函数 ,给你一个稳定的函数,它总是调用最新的fn。内部就是useLatest(fn)加useCallback(() => ref.current(...args), [])。当回调本身就是你要交给子组件、effect 或订阅的东西,并且你希望它的身份永不改变时用它。useEffectEvent(React 19.2+)是useEvent的内置版本,但限制只能从 effect 里调用------返回的函数不稳定,不能作为 prop 传递,也不能加进依赖数组。
经验法则:包函数用 useEvent;包值用 useLatest。 两者可以组合------经典的"只订阅一次、对最新状态做出反应",通常要么给处理器套一个 useEvent,要么给它读的每个值各套一个 useLatest,两种都行。useLatest 完胜的场景是回调根本不归你包:SDK 的 onChange、一个 Promise 的后续、一个你只构造一次的 Observer。
模式
在 await 之后
开头的自动保存就是这个形状:任何先 await、然后需要知道世界有没有变的处理器。第二个常见变体是发生在处理器(而不是 effect)里的请求竞态:
tsx
function Search() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Item[]>([]);
const latestQuery = useLatest(query);
async function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const q = e.target.value;
setQuery(q);
const items = await api.search(q);
if (latestQuery.current !== q) return; // 更新的一次按键赢了------丢掉这个响应
setResults(items);
}
return <input value={query} onChange={onChange} />;
}
在 useEffect 里,你会用 React 文档里的 let ignore = false 清理标志来做这件事。事件处理器没有清理的位置,所以由 ref 来承担"我还相关吗?"这个检查。(要对调用本身做防抖,看 useDebounceFn------那是另一个问题。)
只创建一次的订阅
任何建立起来昂贵或有状态的东西------地图、图表、WebSocket、ResizeObserver------都应该只创建一次然后读取最新状态,而不是每敲一个键就被拆掉重建:
tsx
function PinMap({ filters }: { filters: Filters }) {
const container = useRef<HTMLDivElement>(null);
const latestFilters = useLatest(filters);
useEffect(() => {
const map = new mapboxgl.Map({ container: container.current!, style: STYLE });
map.on("moveend", () => {
loadPins(map.getBounds(), latestFilters.current); // 最新的 filters,地图只建一次
});
return () => map.remove();
}, []); // ✅ 这里的空依赖是诚实的------里面没有任何东西会过期
return <div ref={container} />;
}
没有这个 ref,你的选择是把 filters 放进依赖(每次筛选变化地图就销毁重建------闪烁、丢失视口、重新下载瓦片),或者空依赖数组外加一条 lint 警告和一个 bug。useLatest 给了你第三个选项:这个 effect 真的什么都不依赖,因为它是透过一个永远最新的 ref 去读的。
定时器
setTimeout 和 setInterval 是教科书级的过期闭包制造机:
tsx
function Toast({ message, onDismiss }: { message: string; onDismiss: () => void }) {
const [paused, setPaused] = useState(false);
const latestPaused = useLatest(paused);
useEffect(() => {
const id = setTimeout(() => {
if (!latestPaused.current) onDismiss(); // 第 5 秒正好悬停着?那就保持打开
}, 5000);
return () => clearTimeout(id);
}, []);
return <div onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>{message}</div>;
}
一次性的以外都别手写:useTimeoutFn 和 useInterval 已经通过 useLatest 保持回调最新,并在其上加了 pause/resume/immediate------本系列的上一篇 React useInterval Hook 就是逐行讲这个的。
@reactuses/core 内部用在哪里
如果你想在生产代码里看这个模式,useLatest 是这个库很大一块功能背后默默干活的主力。useEventListener 用它包住你的处理器,这样 addEventListener 每个元素只跑一次,而不是每次渲染跑一次。useClickOutside、useIntersectionObserver、useResizeObserver 和 useMutationObserver 都只构造一次 observer,然后在里面调用 savedCallback.current。useRafFn 在不取消动画循环的前提下读最新的帧回调。useUnmount 用它保证你在第一次渲染时传入的清理函数,不会在卸载时带着第一次渲染的值执行。同样的五行,每一次。
值得知道的坑
- 它不是响应式的。 写或读
.current从不触发重新渲染。如果一个变化应该出现在屏幕上,它属于 state------useLatest是给读 的回调用的,不是给显示的值用的。 - 不要在渲染期间读它。 因为 ref 是在 layout effect 里更新的,在渲染期间它落后一次提交。这是设计使然,从回调里读时永远不会有问题;但如果你把
latest.current放进 JSX 或useMemo,问题立刻出现。那些地方直接读值本身。 - 不要把它放进依赖数组指望它触发什么。 ref 的身份在组件整个生命周期里都是稳定的,所以
[latestFoo]等价于[]。这是特性------意味着读它的 effect 永远不会因为它而重跑------但也意味着你不能用它来响应变化。 - 滞后窗口是真实存在的,也是极小的。 在渲染和 layout effect 提交之间,
.current是上一次渲染的值。这个窗口里不会有任何用户可见的东西运行(没有事件、没有定时器、没有 passive effect),所以实践中不是问题,而这正是永远不把被丢弃的渲染泄漏进 ref 所付出的代价。 - 有时候重启正是你要的。 如果你的 effect 应该在某个值变化时重新运行------
roomId变了就重连 socket------那就照常把roomId放进依赖。useLatest只用于那些回调应该不引起重启 地读取的值。在同一个 effect 里两者混用(依赖里放[roomId],里面读latestFilters.current)完全正常。 - SSR 安全。 它就是一个 ref 加一个同构 layout effect;不碰
window,也不会有 hydration 不匹配,因为它从不渲染任何东西。
什么时候不该用 useLatest
- 这个值要显示出来 → 永远是
useState。 - 你在包一个要交给子组件或 effect 的函数 →
useEvent(或者 React 19.2+ 上、只在 effect 内使用的useEffectEvent)。 - 你想要上一次渲染的值 →
usePrevious------useLatest的镜像。 - 你想在
await之后 set state 之前知道组件是否还挂载着 →useMountedState就是那个布尔值。 - "过期"的是定时器或 DOM 回调 → 你多半想要的是
useInterval、useTimeoutFn或useEventListener,它们已经替你把useLatest这套走完了。
要点
- 一个活得比自己那次渲染更久的回调------定时器、
await、订阅、SDK 钩子------看到的是创建它那次渲染的 props 和 state。这是闭包在尽本分;只有当你需要现在 却拿到那时,它才是 bug。 useLatest是一个在 layout effect 里与某个值保持同步的 ref:从任何回调读都是最新的,从不引起重新渲染,从不改变身份,从不泄漏被丢弃的渲染。- 值 →
useLatest。函数 →useEvent。要显示 →useState。上一次渲染 →usePrevious。 - 从回调里读
.current,永远不要从渲染里读;把真正的重启触发条件(roomId、url)留在依赖数组里------useLatest是给回调透过它去读 的东西用的,不是给重启它的东西用的。
useLatest、useEvent、usePrevious 以及另外 110+ 个 SSR 安全、TypeScript 优先的 hooks 都在 @reactuses/core 里------一次安装,可 tree-shake,没有需要你照看的依赖。
bash
npm install @reactuses/core