useRef 与稳定引用——什么时候它是逃生口,什么时候它是坏味道

前面几篇已经把几件事讲清楚了:
useEffect不该管组件内部的纯计算。闭包问题本质上是渲染快照和函数引用的问题。
很多所谓“状态管理”,其实只是把计算错写成了 state。
走到这一步,通常会遇到一个新的问题:
有些东西我不想放进 state,因为它不该触发重渲染; 但我又确实想让它跨渲染保留下来。 那它到底该放哪?
答案就是:useRef。
但也正因为它“不触发重渲染”,所以它非常像一把好用的旁门兵器: 用对了,能解决闭包、订阅、DOM 控制这些很棘手的问题; 用错了,组件的数据流就开始偷偷分叉。
一、先把 useRef 的本质说清楚
React 官方对 useRef 的定义非常直接:
它能在多次渲染之间保存一个值。
修改它不会触发组件重新渲染。
也就是说,useRef 更像一个“跨 render 持久存在的可变盒子”:
const ref = useRef(initialValue);
// ref.current 可读可写
ref.current = nextValue;它和普通变量的区别在于:
普通变量每次 render 都会重新执行、重新声明。
ref 会在组件生命周期里一直保留同一个对象。
它和 state 的区别在于:
state 改了,React 会重新渲染。
ref 改了,React 不会知道,也不会更新 UI。
所以一句话总结:
useRef适合存“需要记住,但不影响当前界面”的值。
这句话非常关键,后面所有判断几乎都能从这里推出来。
二、最典型的两个 useRef 用途
1. 访问 DOM
这是大家最熟悉的:
function Input() {
const inputRef = useRef<HTMLInputElement | null>(null);
function focusInput() {
inputRef.current?.focus();
}
return (
<>
<input ref={inputRef} />
<button onClick={focusInput}>聚焦</button>
</>
);
}这种场景非常标准,因为你需要一个“指向真实 DOM 节点的句柄”,然后做一些命令式操作,比如:
聚焦。
滚动到某个位置。
读取尺寸。
和第三方 DOM 库对接。
这里 ref 是 React 官方明确提供的 escape hatch:
当声明式 UI 不够表达某些行为时,你可以短暂地下潜到 DOM 层。
2. 保存不触发渲染的持久值
这才是实际项目里更容易被低估的用法。
比如:
上一次的值。
定时器 ID。
当前订阅实例。
某个只给 effect / 回调用的最新参数。
例子:
const timerRef = useRef<number | null>(null);
useEffect(() => {
timerRef.current = window.setInterval(() => {
console.log('tick');
}, 1000);
return () => {
if (timerRef.current !== null) {
clearInterval(timerRef.current);
}
};
}, []);这里如果你把 timerId 放到 state 里,反而是错的,因为:
它不参与 UI 渲染。
它只是一个内部控制句柄。
改它没必要触发界面刷新。
这类“值要保留,但不该驱动 UI”的场景,才是 useRef 真正大规模发挥作用的地方。
三、useRef 为什么能解决 stale closure 问题?
前一篇讲闭包时提过,很多旧值问题的根源是:
定时器 / 监听器 / 异步回调注册在旧 render。
它们拿到的也是旧 render 里的变量。
而 useRef 之所以常被拿来解这个问题,是因为:
effect 或回调可以始终持有同一个 ref 对象;
而这个 ref 的
.current可以在每次 render 后更新成最新值。
比如:
function SearchBox() {
const [query, setQuery] = useState('');
const queryRef = useRef(query);
useEffect(() => {
queryRef.current = query;
}, [query]);
useEffect(() => {
const id = setInterval(() => {
console.log(queryRef.current);
}, 3000);
return () => clearInterval(id);
}, []);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
);
}这里定时器只注册一次,但每次读到的都是最新 query。
原因不是 React 帮你“热更新了闭包”,而是闭包拿到的是那个一直没变的 queryRef 对象,而你每次改的是它里面的 current。
所以 useRef 在这类场景中的角色是:
不去重建外部订阅,而是让订阅去读一个持续更新的“最新值容器”。
这点很重要,因为它决定了 ref 该在什么时候出场。
四、什么时候应该用 ref,而不是 state?
这是整篇最实用的问题,判断标准可以很简单。
用 state 的情况
如果这个值会影响渲染结果,就该用 state。
比如:
弹窗是否打开。
当前选中项。
加载中状态。
表单输入值。
因为这些一变,界面就该跟着变。
用 ref 的情况
如果这个值只是组件内部记忆,不直接影响界面,就优先考虑 ref。
比如:
上一个 props 值。
计时器 ID。
一个第三方实例。
是否已经提交过。
滚动位置缓存。
某个供异步回调读取的最新值。
一句非常好记的话: > 想让 React 重新画界面,用 state; > 只想让组件自己记住点东西,用 ref。
五、一个最容易写对也最容易写歪的例子:上一个值
很多教程都喜欢用 usePrevious 当 useRef 示例,因为它确实很典型:
function usePrevious<T>(value: T) {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}然后这样用:
const prevCount = usePrevious(count);这类代码成立,是因为:
“上一个值”本身不直接参与当前 UI 的更新机制;
它只是一个辅助信息。
如果你为了拿“上一个值”,去再开一个 state:
const [prevCount, setPrevCount] = useState(count);
useEffect(() => {
setPrevCount(count);
}, [count]);那就又绕回了“effect 同步状态”的老问题。
所以这里 ref 很合适,它提供的是“记忆能力”,不是“渲染驱动能力”。
六、什么时候 useRef 是坏味道?
到这里,容易进入另一个极端: 既然 ref 不触发重渲染、还能存最新值,那是不是很多 state 都可以换成 ref?
绝对不是。
下面这些情况,通常都说明你已经在滥用 ref。
1. 用 ref 存本该驱动 UI 的数据
比如:
const openRef = useRef(false);
function handleOpen() {
openRef.current = true;
}然后你还想让界面跟着显示弹窗。
这就错了,因为 React 根本不知道 openRef.current 变了,页面不会更新。
只要某个值改变后,你期待界面立刻重新反映出来,那它就不该是 ref,而应该是 state。
2. 在 render 期间读写 ref,偷偷绕开 React 数据流
React 官方专门提醒过:
不要在渲染期间读写
ref.current,除非是初始化这种极少数可预测场景。
比如这种就很危险:
function Component() {
const countRef = useRef(0);
countRef.current += 1;
return <div>{countRef.current}</div>;
}这段代码的问题在于:
render 本该是纯的;
你却在 render 里偷偷改了外部可变值;
这样会让组件行为变得不可预测,尤其在严格模式和未来特性下更危险。
所以 ref 可以变,但不要把它当成“render 阶段的可变全局变量”。
3. 用 ref 回避依赖数组,而不是解决问题
这是团队里很常见的一种“高级坏味道”。
比如本来 effect 正常应该依赖 userId,结果有人为了避免重跑,硬是这样写:
const userIdRef = useRef(userId);
useEffect(() => {
userIdRef.current = userId;
}, [userId]);
useEffect(() => {
fetchUser(userIdRef.current);
}, []);这段代码也许“能跑”,但往往是在偷偷改变语义:
原本这个 effect 应该随
userId变化重跑。现在你把它改成了“只执行一次,但读最新值”。
这两者不是一回事。
所以一个很重要的原则是:
ref 应该用来表达“我不想重建这个外部订阅,但我想让它读到最新值”; 而不是用来逃避“这个 effect 本来就该跟依赖变化一起更新”的事实。
七、一个完整重构例子:防抖搜索该怎么写
这个场景很适合把 useRef、闭包和稳定引用串起来。
先看一个常见坏代码:
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('');
useEffect(() => {
const id = setTimeout(() => {
onSearch(query);
}, 500);
return () => clearTimeout(id);
}, [query, onSearch]);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
);
}这段其实不算错。 它表达的是:每次 query 变化,就重新启动一个 500ms 定时器。 如果你的意图就是“随 query 重建防抖计时”,它是合理的。
但如果你想把“防抖函数”抽成一个稳定工具,就很容易写出这种有 stale closure 的版本:
function useDebouncedCallback(fn: (...args: any[]) => void, delay: number) {
const timerRef = useRef<number | null>(null);
return useCallback((...args: any[]) => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
fn(...args);
}, delay);
}, [delay]);
}这段的坑在于:
callback 被缓存住了;
但里面闭包的
fn可能已经过期;如果外部传进来的函数变了,这里不一定拿的是最新版本。
更稳妥的写法是把最新 fn 放到 ref 里:
function useDebouncedCallback<T extends (...args: any[]) => void>(
fn: T,
delay: number
) {
const fnRef = useRef(fn);
const timerRef = useRef<number | null>(null);
useEffect(() => {
fnRef.current = fn;
}, [fn]);
return useCallback((...args: Parameters<T>) => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
fnRef.current(...args);
}, delay);
}, [delay]);
}这里的结构就很清楚:
timerRef保存计时器句柄。fnRef保存最新回调。返回的函数只在
delay变化时更新。
这就是 useRef 非常典型的一种高阶用途:
让工具函数保持稳定,同时又能拿到最新业务逻辑。
八、useRef 和 useCallback 的关系,到底怎么配合?
前一篇已经讲过,useCallback 解决的是函数引用稳定,不是最新值问题。
而 useRef 解决的是跨 render 持久保存一个可变值。
所以它们经常搭配出现,但分工不同:
useCallback:决定“这个函数对象要不要换”。useRef:决定“函数内部需要读的那个最新值放哪”。
一个典型模式是:
const latestHandlerRef = useRef(onChange);
useEffect(() => {
latestHandlerRef.current = onChange;
}, [onChange]);
const stableHandler = useCallback((value: string) => {
latestHandlerRef.current(value);
}, []);这里:
stableHandler引用是稳定的;但它执行的逻辑始终是最新
onChange。
这类模式在封装 hook、桥接第三方事件系统时特别常见。
不过要强调一句:
如果你只是普通业务组件,先别急着上这种模式。 很多时候老实写依赖数组,代码反而更简单。
ref + callback 组合适合的是“工具化场景”“订阅场景”“不想重建场景”,而不是所有地方都上。
九、这一篇可以直接带走的 7 条规则
useRef是一个跨渲染持久存在的可变盒子,改它不会触发重渲染。它适合保存“不影响 UI、但需要记住”的值,比如 DOM 节点、定时器 ID、第三方实例、最新回调。
如果某个值变化后应该立刻反映到界面上,那它应该是 state,不是 ref。
stale closure 场景下,ref 常用来让“长寿命回调”读到最新值,而不必频繁重建订阅。
不要在 render 期间随意读写
ref.current,这会破坏 render 的纯度。不要用 ref 去逃避本来就应该存在的 effect 依赖。
useRef和useCallback经常搭配,但一个解决“值持久化”,一个解决“函数引用稳定”,别混为一谈。