React useTimeout Hook:声明式 setTimeout 与自动清理 (2026)

这是一个「已复制!」按钮。每个代码库里都有一个,而这个版本有三个 bug:

tsx 复制代码
function CopyButton({ text }: { text: string }) {
  const [copied, setCopied] = useState(false);

  useEffect(() => {
    if (!copied) return;
    setTimeout(() => setCopied(false), 2000);
  }, [copied]);

  return (
    <button onClick={() => { navigator.clipboard.writeText(text); setCopied(true); }}>
      {copied ? "已复制!" : "复制"}
    </button>
  );
}

它从不清除定时器,所以在倒计时中途卸载会留下一个指向已死组件的回调。它在每次 copied 变化时重新挂载,而不是干净地重启。而在 React 18 的 StrictMode 下,effect 在挂载时会跑两次,于是你本想要一个定时器,却拿到了两个。补上漏掉的 clearTimeout 能修掉泄漏,但修不掉问题的形状:定时器的生命周期现在被绑进了依赖数组,而你依然没有办法在点击事件里取消它、按需重启它,或者问一句「它还在跑吗?」

setTimeout 是一个「发射后不管」的浏览器原语。React 组件可不是发射后不管的------它们会卸载、会重渲染、会改主意。@reactuses/core 里的 useTimeoutuseTimeoutFn 把这个鸿沟填上了:交给你的不是一个需要小心伺候的数字,而是一份状态加两个控制函数。这篇文章讲它们底层到底做了什么、那个所有人都会踩的行为(延迟是依赖,回调不是)、一个会悄悄污染你参数的 start() 陷阱,以及值得直接抄走的几个模式。

快速开始

bash 复制代码
npm install @reactuses/core
tsx 复制代码
import { useTimeoutFn } from "@reactuses/core";

function CopyButton({ text }: { text: string }) {
  const [copied, setCopied] = useState(false);
  const [, startReset] = useTimeoutFn(() => setCopied(false), 2000, {
    immediate: false,
  });

  return (
    <button
      onClick={async () => {
        await navigator.clipboard.writeText(text);
        setCopied(true);
        startReset();
      }}
    >
      {copied ? "已复制!" : "复制"}
    </button>
  );
}

没有 effect,没有依赖数组,没有需要记着写的清理。定时器由一次点击、而不是由一次渲染来启动;卸载时自动清除;在提示还挂着的时候再点一次「复制」,会重新开始这两秒,而不是在第一个定时器上再叠一个。

两个 Hook,一台引擎

两个 hook 返回的是同一个三元组------库里把它叫做 Stoppable

tsx 复制代码
type Stoppable = [isPending: boolean, start: Fn, cancel: Fn];

它们的区别只在于:时间到了之后发生什么。

useTimeoutFn(cb, ms, options?) 执行你的回调。当这个「到期」本身有事要做时用它------关掉 toast、重置标志位、发一个埋点。

useTimeout(ms?, options?) 不跑你的任何回调。它把 isPendingtrue 翻成 false 并触发一次重渲染。当这个「到期」本身就是状态时用它------「300ms 过了没?」就是全部的问题。

useTimeout 字面上就是把回调那个位置让给「强制重渲染」的 useTimeoutFn

tsx 复制代码
export const useTimeout: UseTimeout = (ms = 0, options = {}) => {
  const update = useUpdate();
  return useTimeoutFn(update, ms, options);
};

这个 useUpdate 是一个两行的 useReducer,对一百万取模递增计数器------就是那个「不发明假 state 也能强制重渲染」的标准技巧,取模是为了让长命组件不会一路飘向 Number.MAX_SAFE_INTEGER。它保证了到期那一刻一定会有一次渲染,哪怕在光靠 isPending 不足以触发渲染的场景下也是如此。正是这一点,让 useTimeout 可以当作一个纯粹的「N 毫秒后重新渲染我」原语来用------比如你要重新读一个并不是 React state 的值时。

默认两者都在挂载时启动。传 { immediate: false },在你自己调用 start() 之前什么都不会发生。

它到底做了什么

实现大约二十行,而每一行都在回答开头那个例子里的某个 bug:

tsx 复制代码
export const useTimeoutFn = (cb, interval, options = {}) => {
  const { immediate = true } = options;
  const [pending, setPending] = useState(() => immediate);
  const savedCallback = useLatest(cb);
  const timer = useRef<ReturnType<typeof setTimeout>>();

  const stop = useEvent(() => {
    setPending(false);
    if (timer.current) clearTimeout(timer.current);
  });

  const start = useEvent((...args: unknown[]) => {
    clearTimeout(timer.current);
    timer.current = setTimeout(() => {
      setPending(false);
      savedCallback.current(...args);
    }, interval);
    setPending(true);
  });

  useEffect(() => {
    if (immediate) start();
    return stop;
  }, [stop, immediate, interval, start]);

  return [pending, start, stop];
};

这里塞进了五个决策,每一个都值得知道,因为每一个之后都会出现在你自己的代码里。

回调住在 ref 里,不在依赖里。 useLatest 在每次渲染提交后把 savedCallback.current 指向最新的那个函数,定时器透过它来调用。所以到期时执行的闭包是你最近一次 渲染里的那个------过期闭包的 bug 没了------但换掉回调不会重启倒计时。一个 5 秒的定时器跑到第 4 秒时还剩 1 秒,哪怕它将要调用的那个函数在这期间已经被重新创建了十次。这是正确的行为,仓库里有测试覆盖,但对于「一个长得像 useEffect 的 hook,输入变了就该重跑」这种预期来说,会让人意外。

延迟确实在依赖里。 interval 位于依赖数组中,所以改动它会拆掉当前定时器、从零开始一个新的。这是刻意的,通常也正是你想要的------但请看下面的坑,因为在渲染里现算的延迟,是造出一个永远跑不完的倒计时的最快方式。

startstop 的引用永不改变。 useEvent 把两者都包进一个空依赖的 useCallback 并转发给 ref,所以你在第 1 次渲染拿到的函数,和第 500 次渲染拿到的是同一个引用。你可以把它们放进依赖数组、传给被 memo 的子组件、或者塞进 context,都不会引发常见的抖动。

start() 先清后设。 在定时器已经在跑的时候调用它不会叠加------而是取消并重启。这就是「再点一次复制」表现正常的原因;也意味着在每次按键时反复调用 start(),你就白得了一份防抖语义(不过 useDebounceFn 把意图说得更清楚)。

pending 是被预置的,不是闪出来的。 useState(() => immediate) 意味着当 immediate 打开时,第一次渲染读到的就已经是 true------挂载时没有 false → true 的闪烁,也没有浪费掉的一次渲染。而且因为 immediate 只是一个普通选项、服务端和客户端取值一致,这个预置值在两边完全相同。这个 hook 里没有任何东西碰 windowdocumentDate,所以它无需守卫就能在服务端渲染,也不会出现 hydration 不匹配。

effect 的清理函数就是 stop 本身,这就是泄漏的修复:卸载时一定清除定时器,无论它当时处于什么状态。

值得抄走的模式

延迟出现的 loading

useTimeout 最好的用途。一个出现 80ms 就消失的 spinner 读起来只是一次闪烁------比完全不显示还糟。解法是只在加载真的慢的时候才显示,而这恰好就是「300ms 过了没?」:

tsx 复制代码
function UserList() {
  const { data, isLoading } = useUsers();
  const [tooSoon] = useTimeout(300);

  if (isLoading) return tooSoon ? null : <Spinner />;
  return <List items={data} />;
}

tooSoon 初始为 true,挂载 300ms 后翻成 false。快速返回的请求在这段间隙里什么都不渲染;慢的才会拿到 spinner。一行,无 state,无 effect。

自动消失 + 悬停暂停

元组里的 cancelstart 让这件事变得很简单------手写版本需要一个 ref 和两个 effect:

tsx 复制代码
function Toast({ message, onDismiss }: { message: string; onDismiss: () => void }) {
  const [, start, cancel] = useTimeoutFn(onDismiss, 5000);

  return (
    <div role="status" onMouseEnter={cancel} onMouseLeave={() => start()}>
      {message}
    </div>
  );
}

注意 onMouseLeave 上的 () => start()。这不是风格偏好------见下面的坑。

冷却按钮

tsx 复制代码
function ResendCodeButton({ onResend }: { onResend: () => void }) {
  const [cooling, startCooldown] = useTimeout(30_000, { immediate: false });

  return (
    <button
      disabled={cooling}
      onClick={() => { onResend(); startCooldown(); }}
    >
      {cooling ? "验证码已发送,请稍候" : "重新发送验证码"}
    </button>
  );
}

immediate: false 是关键:按钮在挂载时是可用的,只有被用过一次之后才进入冷却。如果你想渲染剩余秒数而不是一个布尔值,那是另一个 hook 的活------useCountDown 会帮你倒数并把数字交给你。

把控制权交还给浏览器

不带参数的 useTimeout() 默认 ms = 0,它依然会推迟到一个宏任务------在绘制之后、在待处理的微任务之后。偶尔这正是你想要的那个逃生口:「先让浏览器把这一帧画出来,我再做那件耗时的事」,而且比一个 requestIdleCallback polyfill 更好推理。如果你要的是每帧执行而不是执行一次,用 useRafFn

值得知道的坑

  • start 会把参数转发给你的回调。 这是一个真实的特性------start(userId) 会把 userId 透传给定时器回调------同时也是一个真实的陷阱,只要调用方是 DOM 事件处理器。onMouseLeave={start} 会把 React 的合成 MouseEvent 直接塞进你的 onDismiss(...)。如果那个回调是 onDismiss(id?: string),你就用一个事件对象当 id 关掉了一个 toast,而 TypeScript 不会拦你,因为 start 的类型是 Fn。包一层:onMouseLeave={() => start()}onClickonBlur 以及任何会传事件的地方,同理。

  • 延迟一变,倒计时就重启------每一次都是。 interval 是依赖,所以下面这个永远不会触发:

    tsx 复制代码
    // 有 bug:每次渲染都是新的延迟,定时器被无限重启
    useTimeoutFn(onDone, Math.max(0, deadline - Date.now()));

    任何按渲染重算的延迟,都会在它跑完之前把时钟清零。传一个稳定的数字,或者 memo 掉它。反过来这个特性也有用:当延迟是真的变了------用户在「3 秒后消失 / 10 秒后消失 / 不消失」之间切换------重启正是对的。

  • 回调变了不会重启它。 上一条的镜像,同样值得记进肌肉记忆。你的回调永远是最新的那个,但它的到期时刻是 start() 执行时定下的那个。

  • cancel() 会把 isPending 设为 false 它是停止,不是暂停------没有「用剩余时间继续」这回事。cancel() 之后再 start(),走的是一个完整的新延迟。如果你需要真正的暂停/恢复语义,得自己记录已经过去的时间,并把剩余时间作为新的延迟传进去。

  • 卸载之后 isPending 会冻结在最后一次渲染的值上。 清理函数调用了 stop(),它清除了定时器并调用 setPending(false)------但这个状态更新落在了一个已卸载的组件上,React 会丢弃它。如果你在测试里快照了这个元组、在 unmount() 之后再读,isPending 依然会是 true。这不是泄漏,也不会有警告;定时器是真的被清掉了。

  • StrictMode 会挂两次,但结果收敛。 在 React 18 的开发模式下,挂载 effect 会执行、清理、再执行一次,所以在 dev 里你会看到两次 setTimeout 调用。永远不会重复触发------stop 清掉了第一个,start 在排新的之前又清了一次------但倒计时实际上是从第二次执行开始算的。实际使用中这是亚毫秒级的差别;但在一个用假定时器精确推进时间的测试里,这个差别是会咬人的。

  • immediate 在挂载时被读取,同时也是依赖。 在后续某次渲染里把 immediatefalse 翻成 true启动定时器,因为它在 effect 的依赖里。用切换它的方式来声明式地武装一个定时器是完全合理的做法------只是别惊讶于它并不是惰性的。

什么时候不该用它

这两个 hook 是对单个 setTimeout 的一层薄而诚实的封装。当你的问题有专门的名字时,对应的 hook 已经处理好了那些你否则要重新踩一遍的边界情况:

  • 按周期重复执行 → 用 useInterval,而不是让一个 timeout 自己重新武装自己。自排程的 timeout 会漂移,而且取消起来极其难受。
  • 「等用户停止输入」 → 回调用 useDebounceFn,值用 useDebounce。你可以 靠每次按键调 start() 来搭出来,但专用 hook 一眼就能读懂。
  • 「每 N 毫秒最多一次」useThrottleFn / useThrottle。timeout 是限流的错误原语;第一次调用就该立刻通过。
  • 可见的倒数useCountDown。用单个 timeout 渲染「4... 3... 2...」意味着你要自己跑一个 tick 循环。
  • 「用户是不是不动了?」useIdle,它已经监听了正确的那组活动事件。
  • 逐帧动画useRafFnsetTimeout 不与合成器对齐,而且在后台标签页里还会继续跑。
  • 只是想在卸载时清理useUnmount。根本不需要定时器。

要点回顾

  • useEffect 里写 setTimeout,逼着你同时手动管四件事:清理、依赖数组、过期闭包、以及缺失的控制能力。对三个错一个,是常态。
  • useTimeoutFn 返回 [isPending, start, cancel],并且天然在卸载时清除。useTimeout 是同一台引擎,只是把回调那格用来触发重渲染------适合「到期本身就是你关心的状态」的场景。
  • 延迟是依赖,回调不是------改延迟会重启倒计时,改回调只会悄悄换掉将要执行的函数。两者都是刻意设计;分清哪个是哪个能省下一个下午。
  • start 会转发参数,所以永远不要把它直接传给 DOM 事件处理器。写 onMouseLeave={() => start()},不要写 onMouseLeave={start}
  • startcancel 的引用永久稳定,isPending 被预置所以挂载时不会闪,而且 hook 里没有任何东西碰浏览器全局对象------它能原封不动地在服务端渲染。

useTimeoutuseTimeoutFnuseInterval,以及另外 110+ 个 SSR 安全、TypeScript 优先的 hook 都在 @reactuses/core 里------一次安装,支持 tree-shaking,没有需要伺候的依赖。

bash 复制代码
npm install @reactuses/core
相关推荐
禁止摆烂_才浅2 小时前
JavaScript 类型判断:instanceof 与 constructor 原理深度解析
前端·javascript·面试
Asize2 小时前
HTTP 明明无状态,登录态怎么就保住了——React + Zustand + JWT 鉴权全流程拆解
前端·javascript
嘟嘟07172 小时前
JWT 登录认证完整流程(React + Vite 实战复习)
react.js·vite
meilindehuzi_a3 小时前
React JWT 登录鉴权实战:从无状态 HTTP 到路由守卫、Zustand 与 Axios 拦截器
前端·react.js·http
爱酱丶3 小时前
VS Code 快速生成Vue3 + TypeScript + Setup 基础空模板
前端·javascript·typescript
Maxkim3 小时前
在 GitHub 仓库里配一个 AI Code Reviewer,自动审查 PR
前端·javascript
NeverSettle_3 小时前
Agent 如何快速调用公司接口?——CLI + Skill 实践与踩坑
前端·javascript·后端
sugar__salt3 小时前
三列布局与 TypeScript 工具类型 Pick / Omit / Partial 详解
前端·javascript·typescript
名字还没想好☜3 小时前
Next.js Route Handler 做 SSE 服务端推送:实时进度条、自动重连与什么时候别用 WebSocket
开发语言·javascript·websocket·react·sse·next.js