跳到主要内容

useEffect 闭包陷阱:为什么你的计数器永远停在 1,以及 React Hooks 速查表怎么救场

用一个真实可复现的 setInterval 闭包陷阱,讲清 useEffect 依赖数组的坑,再教你按场景选对 Hook:useState、useReducer、useDeferredValue 还是自定义 useDebounce。

发布于

useEffect 闭包陷阱:为什么你的计数器永远停在 1,以及 React Hooks 速查表怎么救场

每个写过两年 React 的人,几乎都被同一个 bug 咬过:一个用 setInterval 做的计数器,跑起来之后数字停在 1,再也不动。代码看起来毫无问题,控制台也不报错。这篇文章用这个真实例子拆解闭包陷阱的成因,顺便讲清楚一件更重要的事:大部分 Hook 用错,不是语法问题,而是选型问题。

一个停在 1 的计数器:输入与输出

先看这段代码,它是可以直接粘进项目里复现的:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <p>已经过去 {count} 秒</p>;
}

实际运行的输出是:

已经过去 0 秒   (第 0 秒)
已经过去 1 秒   (第 1 秒)
已经过去 1 秒   (第 2 秒,不再变化)
已经过去 1 秒   (第 3 秒,永远停在这里)

原因:useEffect 的依赖数组是 [],effect 只在挂载时跑一次,定时器回调捕获的是首次渲染时的 count,值是 0。于是每秒执行的都是 setCount(0 + 1),React 发现新值和旧值一样,连重渲染都省了。

修复只改一行,把闭包里的值换成函数式更新:

setCount(c => c + 1);

改完后的输出才是预期的 0、1、2、3 递增。c 由 React 在调用时传入最新值,不依赖闭包,依赖数组留空也是安全的。

选错 Hook 比写错 Hook 更常见

闭包陷阱属于"写错",但我在 review 别人代码时,见得更多的其实是"选错"。React 官方文档(react.dev 的 Hooks 参考页)目前列出了 17 个内置 Hook,从 useState 到 React 19 新增的 useActionStateuseOptimistic。17 个听起来不多,但每个都有自己的反模式区:

  • useState 存"从已有 state 算出来的值",其实直接在渲染体里算就行,顶多包一层 useMemo;
  • useEffect 监听 props 变化去 setState,其实换个 key 让组件重置更干净;
  • 在只有一个布尔开关的组件里上 useReducer,代码量反而翻倍。

这些判断没法靠记忆,因为"什么时候不该用"恰恰是官方文档里最分散的内容。我们把它做进了 React Hooks 速查表:每个 Hook 一张卡片,除了签名和示例,专门有一栏"什么时候别用它",17 个官方 Hook 之外还收了 28 个高频自定义 Hook(useDebounceuseLocalStorageuseOnClickOutside 这类),总共 45 个,支持按名字即时过滤。

我重构搜索框时的真实对比

上个月我重构站内一个 2,000 条数据的搜索列表,输入时整个列表跟着每次按键重算,中文输入法打一个词组卡顿明显。我先试了 useDeferredValue,让列表用滞后的搜索词渲染,紧急的输入框更新优先;打字立刻流畅了,但列表仍然在每次按键后重算一遍,只是不阻塞输入。后来我换成防抖方案,在速查表的 useDebounce 卡片里直接抄了实现,延迟设 300 毫秒(卡片备注写得很直接:200 到 500 毫秒是搜索场景的合理区间,太短等于没防抖,太长用户会以为坏了)。换完之后,连续输入"计算器"三个字,列表只在停止输入 300 毫秒后重算一次,而不是六次(拼音加选词)。两种方案我都留了分支,最终上线的是防抖版,因为它真正减少了计算次数,而 useDeferredValue 只是调整了优先级。

这件事给我的教训是:useTransitionuseDeferredValueuseDebounce 三个东西解决的是三个不同的问题,名字却长得像近义词。速查表里这三张卡片挨着放,各自写明了适用边界,比来回翻三篇文档快得多。

TypeScript 下的 Hook 签名也别背

如果你的项目是 TypeScript(2026 年了,大概率是),Hook 的泛型签名又是一层记忆负担:useState<T> 的初始值可以是 T 也可以是 () => T,useReducer 的第三个惰性初始化参数怎么标类型,useRef<HTMLInputElement>(null) 为什么必须传 null。速查表里每个签名都是带泛型的完整版本,配合 TypeScript 速查表 里的泛型和工具类型章节一起看,基本能覆盖日常组件开发的类型问题。在 Next.js 项目里用 Server Components 时,哪些 Hook 只能在客户端组件里调用,也可以对照 Next.js 速查表'use client' 部分确认。

三条可以直接带走的判断

  1. 定时器、订阅、事件监听里读 state,一律用函数式更新或 useRef,不要指望依赖数组帮你刷新闭包。
  2. useEffect 之前先问一句:这真的是副作用吗? 从 props 或 state 派生数据不是副作用,放渲染体里算;只有和外部系统(网络、DOM、定时器)打交道的才进 effect。
  3. 拿不准选哪个 Hook,先查"什么时候别用",再查"什么时候用。" 排除法在 17 个官方 Hook 里比正向记忆靠谱得多。

闭包陷阱不会因为 React 出了新版本就消失,它是 JavaScript 闭包语义和 React 渲染模型叠加的必然产物。能做的就是把判断规则放在手边,写的时候查一下,比上线后盯着停在 1 的计数器抓头发便宜。


Made by Toolora · Updated 2026-06-12