2026年6月29日

React useDebounce Hook:給狀態與回呼做防抖(2026)

你有一個搜尋框。使用者輸入 react hooks,你的元件就在每一次按鍵上發一個 API 請求——一個查詢發了十一個請求,其中十個在回傳時早就過期了。所有人都會想到的修法是防抖(debounce):等輸入停下來,再發一次。而所有人都會寫錯的修法,是在元件裡用 setTimeout 手寫這個防抖——過期閉包、漏掉的清理、re-render 抖動,會悄悄把它弄壞。

useDebounce 就是把這件事做對的那個 hook。本文講清楚你真正需要的兩種形態——給做防抖、給回呼做防抖——什麼時候用哪個,以及怎麼 cancel(取消)或 flush(立即執行)待處理的呼叫。這裡寫的全是真實的 @reactuses/core API,SSR 安全且帶型別。

為什麼不直接用 setTimeout?

防抖本身很簡單:把一個函式推遲到一段安靜期之後再執行,每來一次新呼叫就重置計時器。(如果你想要完整的概念拆解——以及它和節流的差別——見 React 中的防抖 vs 節流。)難的是在 React 元件裡做這件事。下面是最直覺的寫法,它帶了三個 bug:

function Search() {
  const [query, setQuery] = useState('');
  const timer = useRef<ReturnType<typeof setTimeout>>();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
    setQuery(value);
    clearTimeout(timer.current);
    timer.current = setTimeout(() => {
      fetchResults(value); // 🐛 見下文
    }, 300);
  }

  return <input value={query} onChange={handleChange} />;
}
  1. 卸載時會洩漏。 如果元件在計時器待處理時卸載,回呼依然會在 300 ms 後觸發——往往是給一個已經消失的元件 setState,或者為使用者早已離開的頁面打 API。
  2. 它會捕捉過期的值。 一旦你防抖的不是原始事件值——而是第二個 state、一個 prop、一個衍生值——閉包凍結的是計時器設定時的它們,而不是觸發時的。
  3. 它會到處複製。 每個需要防抖的地方都重寫一遍 useRef + clearTimeout,每份複本都是一次忘掉清理的機會。

一個 hook 在一個地方把這三件事都修好。ReactUse 提供了兩個,內部基於久經考驗的 lodash.debounce,所以那些邊角情況(前沿觸發、最大等待、後沿觸發)都已經處理好了。

useDebounce —— 給值做防抖

最常見的場景:你有一個快速變化的值,你想要它的第二份、滯後的複本,只在一切都穩定下來之後才更新。那份複本才是你餵給昂貴運算的東西。

import { useState, useEffect } from 'react';
import { useDebounce } from '@reactuses/core';

function Search() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (!debouncedQuery) return;
    fetchResults(debouncedQuery);
  }, [debouncedQuery]);

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="搜尋…"
    />
  );
}

簽名是 useDebounce(value, wait?, options?),它回傳防抖後的值,型別和輸入一致:

const debounced = useDebounce(value, 300);

輸入(query)在每次按鍵都更新,所以受控的 <input> 始終跟手——這是你綁到 DOM 上的值。輸出(debouncedQuery)只在使用者停止輸入 300 ms 後才追上,所以它是你放進 effect 相依陣列裡的值。API 變成每次停頓發一次、而不是每次按鍵發一次,而你的輸入框永遠不卡,因為你打字進去的那個東西從來就不是被防抖的那個。

這套模式——給 UI 用快值、給副作用用防抖後的值——就是全部要點。把它們保持成兩個獨立的變數,其餘的自然就順了。

useDebounceFn —— 給回呼做防抖

給值做防抖在「你想限制的東西是 state」時很好用。但有時候你想防抖的是一個帶參數的動作——自動儲存、埋點、resize 處理——而不想先繞過 state。那就是 useDebounceFn

import { useDebounceFn } from '@reactuses/core';

function Editor({ docId }: { docId: string }) {
  const { run } = useDebounceFn((content: string) => {
    saveDraft(docId, content);
  }, 1000);

  return (
    <textarea onChange={(e) => run(e.target.value)} />
  );
}

useDebounceFn(fn, wait?, options?) 回傳一個帶三個成員的物件:

const { run, cancel, flush } = useDebounceFn(fn, 1000);
  • run —— 防抖後的函式。你想呼叫多少次就呼叫多少次;fn 只在呼叫停下來 wait ms 之後才真正執行。它會把所有參數透傳過去,所以 run(content) 會呼叫 fn(content)
  • cancel —— 丟棄任何待處理的呼叫。什麼都不會觸發。
  • flush —— 立刻觸發待處理的呼叫,而不是等計時器走完。

關鍵在於,run 永遠呼叫你最新版本的 fn。hook 內部把你的回呼存在一個 ref 裡,所以即便防抖包裝只建立一次,它也永遠不會過期——setTimeout 版本裡那個 docId 閉包問題在這裡根本不存在。而且這個 hook 在卸載時會自動取消任何待處理的呼叫,所以 bug #1 也沒了。

useDebounce 其實就是構建在 useDebounceFn 之上的——它給一次 setState 呼叫做防抖,然後把結果值交給你。同一個引擎,兩種手感。

cancel 和 flush 的實戰

cancel/flush 這一對,正是裸 setTimeout 做起來很痛、而 hook 做起來很簡單的地方。兩個真實例子:

function CommentBox() {
  const { run: autosave, cancel, flush } = useDebounceFn(
    (text: string) => saveDraft(text),
    2000,
  );

  return (
    <>
      <textarea onChange={(e) => autosave(e.target.value)} />
      {/* 使用者點了「發布」—— 立刻持久化,別等那 2 秒 */}
      <button onClick={() => flush()}>發布</button>
      {/* 使用者點了「丟棄」—— 扔掉待處理的自動儲存 */}
      <button onClick={() => cancel()}>丟棄</button>
    </>
  );
}

flush 保證在發出 post 請求之前,飛行中的草稿已經寫下;cancel 保證被丟棄的草稿不會在一拍之後又被儲存。兩者都只是一次呼叫。

用值還是用回呼?

一個快速判斷規則:

  • 當你防抖的是某個會被別處讀取的 state 時——搜尋詞、篩選條件、餵給圖表的滑桿值——用 useDebounce。你要的是一個滯後的
  • 當你防抖的是一個帶參數的動作時——自動儲存、打日誌、直接發網路請求——用 useDebounceFn。你要的是一個滯後的函式,外加 cancel/flush 控制。

如果你發現自己建立一個 state 只是為了防抖它、然後馬上觸發一個 effect,那 useDebounceFn 通常是更直接的工具。

調參:leading、trailing 和 maxWait

可選的第三個參數會原樣傳給 lodash.debounce,所以你拿到的是它完整的選項物件:

useDebounce(value, 300, {
  leading: false,  // 第一次呼叫時不觸發(預設)
  trailing: true,  // 停頓之後觸發(預設)
  maxWait: 1000,   // …但總等待永遠不超過 1 秒
});

兩個值得知道的旋鈕:

  • leading: true第一次呼叫時立刻觸發,然後再對其餘呼叫做防抖。適合「先即時回應、再穩定下來」的互動——按鈕的第一次點擊很跟手,而快速連點會被吸收。
  • maxWait 給總延遲封頂。純後沿防抖下,一個連續打字十秒的使用者在停下來之前會得到次更新。maxWait: 1000 強制在 burst 中途至少每秒更新一次——這就是一個「活著的」搜尋框和一個「凍住的」搜尋框之間的差別。

SSR 安全

這兩個 hook 在伺服器端渲染時都是安全的。它們在 render 期間不碰任何 windowdocument 或瀏覽器計時器——防抖的工作只在 effect 裡跑,而 React 從不在伺服器端執行 effect。把它們丟進 Next.js、Remix 或 Astro 元件,不用寫 typeof window 守衛,也不用追 hydration 警告。(如果 SSR 安全是你程式碼庫裡反覆出現的主題,SSR 安全的 React Hooks 講得更深。)

限流家族

useDebounce 在 ReactUse 裡有三個近親;按你在限制什麼以及你要哪種形態來挑:

Hook限制的是…策略
useDebounce防抖(停頓後觸發)
useDebounceFn回呼防抖,帶 cancel/flush
useThrottle節流(固定頻率觸發)
useThrottleFn回呼節流,帶 cancel/flush

節流這一對和防抖這一對完全對稱——同樣的 (value/fn, wait, options) 簽名、同樣的回傳形態——但它強制一個穩定的節奏,而不是等到安靜。該用節流的是那些應該在連續手勢進行中更新的東西(捲動位置、拖曳座標、即時進度讀數);該用防抖的是那些應該只在手勢結束後更新的東西(搜尋、自動儲存、驗證)。完整的心智模型在 React 中的防抖 vs 節流:什麼時候用哪個

重點回顧

  • 在元件裡手寫的 setTimeout 防抖預設就帶三個 bug:卸載時洩漏、捕捉過期閉包、到處被複製。
  • useDebounce(value, wait) 給你一個值的滯後複本——往快的那個裡打字,用慢的那個跑 effect。搜尋框即時建議的完美選擇。
  • useDebounceFn(fn, wait) 給一個動作做防抖,並交給你 { run, cancel, flush }run 永遠呼叫你最新的回呼(沒有過期閉包),並在卸載時自動取消。
  • flush 提前提交一個待處理的呼叫(提交),用 cancel 丟棄它(丟棄)。
  • 第三個參數就是 lodash.debounce 的選項——leading 實現首呼即觸發,maxWait 給延遲封頂,讓長 burst 也能更新。
  • 兩者都 SSR 安全,並和 useThrottle/useThrottleFn 一起覆蓋固定頻率的場景。

@reactuses/core 拿走它們,把你的 clearTimeout 樣板程式碼刪掉吧。