debounce(デバウンス)とは「呼び出しが止まってから一定時間後に1回だけ実行する」仕組み、throttle(スロットル)とは「一定時間に最大1回だけ実行する」仕組みです。 どちらも、入力・スクロール・リサイズのように短い間隔で大量に発生するイベントの処理回数を減らすために使います。

この記事では、50ミリ秒ごとに21回呼び出したときに、何もしない場合・debounce・throttle・lodash 版がそれぞれ「いつ・何回」実行されたかを Node.js 22.22.2 で実測したログ を載せます。そのうえで、leading・trailing・maxWait・cancel・flush・this と引数の受け渡しに対応した 型付きの TypeScript 実装(TypeScript 7.0.2 の strict で型チェック済み)、用途別の判断表、React 19 の hook 版、関数を毎回作り直して効かなくなる失敗例までまとめます。


debounce と throttle の違い 早見表

「最後の結果だけ欲しい」なら debounce、「途中経過も一定間隔で欲しい」なら throttle です。

項目 debounce throttle
一言でいうと 止まってから1回 一定間隔で1回
実行タイミング(既定) 最後の呼び出しから wait 後 最初にすぐ+以後 wait ごと+最後
呼ばれ続けている間 実行されない(maxWait で上限を付けられる) wait ごとに実行され続ける
50ms間隔×21回、wait=200ms の実測 1回(1205ms) 7回(0, 201, 402, 602, 802, 1004, 1205ms)
向いている用途 検索候補の取得、入力チェック、自動保存、リサイズ後の再計算 スクロール位置の監視、ドラッグ、無限スクロール、送信ログの間引き
代表的なオプション leading / trailing / maxWait leading / trailing
関係 ― debounce に maxWait = wait を付けたものと同じ動き
ボタン連打の防止 leading: true, trailing: false で最初の1回だけ 間隔内の2回目以降を無視

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「JavaScript debounce・throttle|違いとTS実装をコピペ解説」


タイムラインで見る違い

同じ「50msごとに21回の呼び出し(0〜1000ms)」に対して、wait=200ms でどこが実行されるかを表にしました。 ● が実行されたタイミングです(実測値を100ms単位に丸めて配置)。

時刻(ms) 0 100 200 300 400 500 600 700 800 900 1000 1100 1200
呼び出し ●● ●● ●● ●● ●● ●● ●● ●● ●● ●● ●
そのまま(21回) ●● ●● ●● ●● ●● ●● ●● ●● ●● ●● ●
debounce(1回) ●
debounce leading(1回) ●
debounce maxWait 500(3回) ● ● ●
throttle(7回) ● ● ● ● ● ● ●
sequenceDiagram
  participant E as イベント(50msごと)
  participant D as debounce(200)
  participant T as throttle(200)
  E->>T: 0ms 呼び出し
  T-->>T: すぐ実行(leading)
  E->>D: 0ms 呼び出し(タイマー開始)
  loop 1000msまで50msごと
    E->>D: 呼び出し(タイマーを延長)
    E->>T: 呼び出し(200msごとに実行)
  end
  Note over E: 1000ms 最後の呼び出し
  D-->>D: 1200ms ごろ1回だけ実行(trailing)
  T-->>T: 1200ms ごろ最後の値で実行(trailing)

debounce は「呼び出しのたびにタイマーを延長する」ので、呼び出しが続くかぎり実行されません。 throttle は「前回の実行から wait たったか」で判断するので、呼び出しが続いても一定間隔で実行されます。


Node.js 22 で発火回数とタイミングを実測

次のコードで、各方式の実行時刻(開始からのミリ秒)を記録しました。 自作の実装(後述の timing.ts)と lodash-es 4.18.1 を同じ条件で並べています。

// src/sim.ts  実行: npx tsc -p . && node dist/sim.js
import { debounce, throttle } from "./timing.js";
import { debounce as lodashDebounce, throttle as lodashThrottle } from "lodash-es";

// 50ms ごとに呼び出し、各方式がいつ何回実行されるかを記録する
const INTERVAL = 50, CALLS = 20, WAIT = 200;
const start = Date.now();
const logs: Record<string, number[]> = {};
const rec = (name: string) => (i: number) => { (logs[name] ??= []).push(Date.now() - start); return i; };

const targets: Record<string, (i: number) => unknown> = {
  "そのまま": rec("そのまま"),
  "debounce(200)": debounce(rec("debounce(200)"), WAIT),
  "debounce(200, leading)": debounce(rec("debounce(200, leading)"), WAIT, { leading: true, trailing: false }),
  "debounce(200, maxWait 500)": debounce(rec("debounce(200, maxWait 500)"), WAIT, { maxWait: 500 }),
  "throttle(200)": throttle(rec("throttle(200)"), WAIT),
  "throttle(200, trailing false)": throttle(rec("throttle(200, trailing false)"), WAIT, { trailing: false }),
  "lodash debounce(200)": lodashDebounce(rec("lodash debounce(200)"), WAIT),
  "lodash throttle(200)": lodashThrottle(rec("lodash throttle(200)"), WAIT),
};

let n = 0;
const timer = setInterval(() => {
  for (const f of Object.values(targets)) f(n);
  if (++n >= CALLS) clearInterval(timer);
}, INTERVAL);
for (const f of Object.values(targets)) f(-1);   // 最初の呼び出し(0ms)

setTimeout(() => {
  for (const name of Object.keys(targets)) {
    const t = logs[name] ?? [];
    console.log(name.padEnd(30), `${String(t.length).padStart(2)}回`,
      t.length > 12 ? `${t.slice(0, 4).join(", ")} … ${t.at(-1)}` : t.join(", "));
  }
}, 1600);

実行結果(2回目の実行。1回目も回数は同じで、時刻が1ms前後ずれただけでした):

そのまま                           21回 0, 50, 100, 150 … 1004
debounce(200)                   1回 1205
debounce(200, leading)          1回 0
debounce(200, maxWait 500)      3回 500, 1002, 1205
throttle(200)                   7回 0, 201, 402, 602, 802, 1004, 1205
throttle(200, trailing false)   5回 0, 201, 403, 653, 854
lodash debounce(200)            1回 1205
lodash throttle(200)            7回 0, 201, 402, 602, 802, 1004, 1205
方式 回数 読み取れること
そのまま 21回 呼ばれた回数だけ処理が走る
debounce(200) 1回 最後の呼び出し(1004ms)から約200ms後に1回
debounce leading 1回 最初の呼び出しですぐ実行し、以後は止まるまで無視
debounce maxWait 500 3回 呼ばれ続けても 500ms ごとに実行+最後に1回
throttle(200) 7回 200ms ごとに実行+最後の呼び出しも反映(trailing)
throttle trailing false 5回 間隔の終わりに実行しないので、次の呼び出し時刻にずれる
lodash 版 1回 / 7回 自作版と同じタイミング

throttle で trailing: false にすると、最後の呼び出し(1004ms)が反映されません。 スクロール位置のように「最終的な値」が大事な処理では、trailing: true(既定)のままにします。


TypeScript 実装(leading / trailing / maxWait / cancel / flush 対応)

そのまま src/timing.ts として保存すれば使える、依存なしの実装です。 動きは lodash の debounce / throttle に合わせており、上の実測でも同じタイミングになりました。TypeScript 7.0.2(strict: true、target: ES2023)で型エラーなしを確認しています。

// timing.ts  依存なしの debounce / throttle(ブラウザ・Node.js 共通)
export interface DebounceOptions {
  /** 最初の呼び出しですぐ実行する(既定 false) */
  leading?: boolean;
  /** 最後の呼び出しから wait ミリ秒後に実行する(既定 true) */
  trailing?: boolean;
  /** 呼ばれ続けても、この時間がたったら必ず1回実行する */
  maxWait?: number;
}

type AnyFn = (...args: any[]) => any;

export interface Debounced<F extends AnyFn> {
  (this: ThisParameterType<F>, ...args: Parameters<F>): ReturnType<F> | undefined;
  /** 待っている実行を取り消す */
  cancel(): void;
  /** 待っている実行があれば、今すぐ実行する */
  flush(): ReturnType<F> | undefined;
  /** 実行待ちがあるか */
  pending(): boolean;
}

export function debounce<F extends AnyFn>(fn: F, wait = 0, options: DebounceOptions = {}): Debounced<F> {
  const leading = options.leading ?? false;
  const trailing = options.trailing ?? true;
  const maxing = options.maxWait !== undefined;
  const maxWait = maxing ? Math.max(options.maxWait ?? 0, wait) : 0;

  let timerId: ReturnType<typeof setTimeout> | undefined;
  let lastArgs: Parameters<F> | undefined;
  let lastThis: ThisParameterType<F> | undefined;
  let lastCallTime: number | undefined;
  let lastInvokeTime = 0;
  let result: ReturnType<F> | undefined;

  function invoke(time: number): ReturnType<F> | undefined {
    const args = lastArgs as Parameters<F>;
    const thisArg = lastThis;
    lastArgs = lastThis = undefined;
    lastInvokeTime = time;
    result = fn.apply(thisArg, args);
    return result;
  }

  function remainingWait(time: number): number {
    const sinceCall = time - (lastCallTime ?? 0);
    const sinceInvoke = time - lastInvokeTime;
    const waitLeft = wait - sinceCall;
    return maxing ? Math.min(waitLeft, maxWait - sinceInvoke) : waitLeft;
  }

  function shouldInvoke(time: number): boolean {
    if (lastCallTime === undefined) return true;
    const sinceCall = time - lastCallTime;
    const sinceInvoke = time - lastInvokeTime;
    return sinceCall >= wait || sinceCall < 0 || (maxing && sinceInvoke >= maxWait);
  }

  function timerExpired(): void {
    const time = Date.now();
    if (shouldInvoke(time)) {
      trailingEdge(time);
      return;
    }
    timerId = setTimeout(timerExpired, remainingWait(time));
  }

  function leadingEdge(time: number): ReturnType<F> | undefined {
    lastInvokeTime = time;
    timerId = setTimeout(timerExpired, wait);
    return leading ? invoke(time) : result;
  }

  function trailingEdge(time: number): ReturnType<F> | undefined {
    timerId = undefined;
    if (trailing && lastArgs) return invoke(time);
    lastArgs = lastThis = undefined;
    return result;
  }

  function debounced(this: ThisParameterType<F>, ...args: Parameters<F>): ReturnType<F> | undefined {
    const time = Date.now();
    const isInvoking = shouldInvoke(time);
    lastArgs = args;
    lastThis = this;
    lastCallTime = time;

    if (isInvoking) {
      if (timerId === undefined) return leadingEdge(time);
      if (maxing) {
        clearTimeout(timerId);
        timerId = setTimeout(timerExpired, wait);
        return invoke(time);
      }
    }
    if (timerId === undefined) timerId = setTimeout(timerExpired, wait);
    return result;
  }

  debounced.cancel = (): void => {
    if (timerId !== undefined) clearTimeout(timerId);
    lastInvokeTime = 0;
    lastArgs = lastThis = lastCallTime = timerId = undefined;
  };
  debounced.flush = (): ReturnType<F> | undefined =>
    timerId === undefined ? result : trailingEdge(Date.now());
  debounced.pending = (): boolean => timerId !== undefined;

  return debounced as Debounced<F>;
}

export interface ThrottleOptions {
  /** 最初の呼び出しですぐ実行する(既定 true) */
  leading?: boolean;
  /** 間隔の終わりに、最後の呼び出しを実行する(既定 true) */
  trailing?: boolean;
}

/** wait ミリ秒に最大1回だけ実行する(debounce の maxWait = wait と同じ) */
export function throttle<F extends AnyFn>(fn: F, wait = 0, options: ThrottleOptions = {}): Debounced<F> {
  return debounce(fn, wait, {
    leading: options.leading ?? true,
    trailing: options.trailing ?? true,
    maxWait: wait,
  });
}

型チェックに使った tsconfig.json です。

{
  "compilerOptions": {
    "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext",
    "strict": true, "outDir": "dist", "rootDir": "src", "types": ["node"], "skipLibCheck": true
  },
  "include": ["src"]
}

オプションの意味

オプション・メソッド 意味 使いどころ
leading: true 最初の呼び出しですぐ実行 ボタン連打の1回目だけ通す、反応を速く見せる
trailing: true(debounce 既定) 最後の呼び出しから wait 後に実行 入力が止まったら検索・保存
maxWait 呼ばれ続けても、この時間ごとに必ず実行 打ち続けても数秒おきに自動保存
cancel() 待っている実行を取り消す 画面を閉じた、フォームをリセットした
flush() 待っている実行を今すぐ行い、戻り値を返す ページ遷移の前に保存を確定
pending() 実行待ちがあるか 「保存中…」の表示

型が効いていることの確認

Parameters<F> を使っているので、元の関数の引数の型がそのまま debounce 後の関数に引き継がれます。

import { debounce } from "./timing.js";
const search = debounce((query: string, page: number) => `${query}:${page}`, 300);
search("ノート", 1);       // OK
search("ノート");          // 引数が足りない
search(123, 1);           // 型が違う
const r: string | undefined = search.flush();
src/typecheck.ts(4,1): error TS2554: Expected 2 arguments, but got 1.
src/typecheck.ts(5,8): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.

戻り値は ReturnType<F> | undefined です。 まだ一度も実行されていないときは undefined になるため、戻り値を使う場合は undefined の確認が必要です。

this と引数・cancel・flush の動作確認

// src/api.ts
import { debounce } from "./timing.js";
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

// 1) this と引数の受け渡し
class Draft {
  saved = 0;
  constructor(public title: string) {}
  save = debounce(function (this: Draft, body: string, reason: string) {
    this.saved++;
    console.log(`保存: ${this.title} / ${body} / ${reason} / 保存回数=${this.saved}`);
    return this.saved;
  }, 100);
}
const d = new Draft("週報");
d.save("下書き1", "入力");
d.save("下書き2", "入力");
d.save("下書き3", "入力");
await sleep(150);

// 2) flush(ページを離れる前に保存)・pending・cancel
d.save("下書き4", "入力");
console.log("pending:", d.save.pending());
console.log("flush の戻り値:", d.save.flush());
console.log("pending(flush後):", d.save.pending());
d.save("下書き5", "入力");
d.save.cancel();
await sleep(150);
console.log("cancel 後の保存回数:", d.saved);
保存: 週報 / 下書き3 / 入力 / 保存回数=1
pending: true
保存: 週報 / 下書き4 / 入力 / 保存回数=2
flush の戻り値: 2
pending(flush後): false
cancel 後の保存回数: 2

3回呼んでも実行は最後の「下書き3」の1回だけで、this(Draft のインスタンス)と2つの引数がそのまま渡っています。 クラスのフィールドとして debounce を作ると、インスタンスごとに別のタイマーを持てます(プロトタイプのメソッドに1つだけ作ると、全インスタンスでタイマーを共有します)。

最小版(オプションが不要なとき)

leading や cancel が不要なら、数行の実装でも十分です。

// mini.ts
export function debounceSimple<A extends unknown[]>(fn: (...args: A) => void, wait: number) {
  let id: ReturnType<typeof setTimeout> | undefined;
  return (...args: A): void => {
    clearTimeout(id);
    id = setTimeout(() => fn(...args), wait);
  };
}
export function throttleSimple<A extends unknown[]>(fn: (...args: A) => void, wait: number) {
  let last = 0;
  return (...args: A): void => {
    const now = Date.now();
    if (now - last >= wait) {
      last = now;
      fn(...args);
    }
  };
}

同じ条件(50ms間隔×21回、wait=200ms)での実行結果:

debounceSimple: [ 1209 ]  throttleSimple: [ 0, 203, 404, 605, 807, 1008 ]

throttleSimple は間隔内の呼び出しを捨てるだけなので、最後の呼び出しが間隔の途中だと反映されません(完全版の throttle は 1205ms にもう1回実行して最後の値を反映します)。最後の値が必要な処理には、完全版を使います。


用途別の判断表

「最後の値だけでよいか」「途中経過も必要か」「画面の描画に合わせたいか」で選びます。

用途 おすすめ 設定の目安 理由
検索入力(候補の取得) debounce 200〜400ms、trailing 打ち終わった値だけ問い合わせればよい
入力中の形式チェック debounce 300〜500ms 打っている途中にエラーを出さない
自動保存 debounce + maxWait 1000ms、maxWait: 5000 打ち続けても一定間隔で保存される
ウィンドウのリサイズ後の再計算 debounce 150〜300ms サイズが決まってから1回計算
スクロール位置の記録・無限スクロール throttle 100〜200ms 途中経過も必要、最後の位置も反映
ドラッグ・マウス追従の描画 requestAnimationFrame 1フレームに1回 画面の更新タイミングにそろう
ボタン連打の防止 debounce leading: true, trailing: false 500〜1000ms 1回目をすぐ実行し、続く連打を無視
送信する計測ログの間引き throttle 1000ms〜 一定間隔で送る
要素の表示判定(見えたら読み込み) IntersectionObserver ― スクロールイベント自体が不要
要素サイズの変化の監視 ResizeObserver ― 要素単位で通知され、resize より的確

ボタン連打の防止は、debounce に加えて「送信中はボタンを disabled にする」と確実です。 間引きはあくまで回数を減らす仕組みで、サーバー側での二重登録対策(一意キーなど)とは役割が異なります。

requestAnimationFrame(rAF)との比較

画面の見た目を更新する処理は、時間で間引くより「次の描画の直前に1回」にそろえる rAF の方が、なめらかで無駄がありません。

// rafThrottle.ts  1フレームに最大1回、最後の引数で実行する
export function rafThrottle<A extends unknown[]>(fn: (...args: A) => void) {
  let frame = 0;
  let lastArgs: A;
  const wrapped = (...args: A): void => {
    lastArgs = args;
    if (frame) return;
    frame = requestAnimationFrame(() => {
      frame = 0;
      fn(...lastArgs);
    });
  };
  wrapped.cancel = () => { cancelAnimationFrame(frame); frame = 0; };
  return wrapped;
}

// 使い方:スクロールに合わせて進捗バーを動かす
const bar = document.querySelector<HTMLElement>("#progress")!;
const onScroll = rafThrottle(() => {
  const max = document.documentElement.scrollHeight - innerHeight;
  bar.style.width = `${(scrollY / max) * 100}%`;
});
addEventListener("scroll", onScroll, { passive: true });
方式 実行間隔 向いている処理 注意点
throttle 指定した ms(例 100ms) 通信・ログ・重い計算 描画のタイミングとずれることがある
requestAnimationFrame 画面の更新ごと(60Hz なら約16.7ms) 位置・サイズ・進捗バーなど見た目の更新 非表示タブでは止まる。通信には不向き
debounce 止まってから1回 最終値だけ必要な処理 呼ばれ続ける間は実行されない

(rafThrottle はブラウザ専用のため実行はしていません。型チェックは lib: ["ES2023", "DOM"]・strict で通ることを確認しています。)


React で使う:hook 版と、よくある失敗

React では、コンポーネントが再描画されるたびに関数本体が実行されます。 その中で debounce(...) を呼ぶと、描画のたびに新しいタイマーを持つ別の関数ができてしまい、間引きが効きません。再描画をまたいで同じ関数を使い回すために、useMemo や useRef を使います。

// hooks.ts  React 18 / 19 用(React 19.3.0 で動作確認)
import { useEffect, useMemo, useRef, useState } from "react";
import { debounce, throttle, type DebounceOptions, type Debounced } from "./timing.js";

/** 値が変わってから delay ミリ秒止まったら、その値を返す */
export function useDebouncedValue<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

/** 再描画をまたいで同じ debounce 関数を返す(最新の callback を呼ぶ) */
export function useDebouncedCallback<F extends (...args: any[]) => any>(
  callback: F, delay = 300, options?: DebounceOptions,
): Debounced<F> {
  const callbackRef = useRef(callback);
  useEffect(() => { callbackRef.current = callback; }, [callback]);
  const { leading, trailing, maxWait } = options ?? {};
  const debounced = useMemo(
    () => debounce(((...args: Parameters<F>) => callbackRef.current(...args)) as F, delay, { leading, trailing, maxWait }),
    [delay, leading, trailing, maxWait],
  );
  useEffect(() => () => debounced.cancel(), [debounced]);  // アンマウント時に取り消す
  return debounced;
}

/** throttle 版 */
export function useThrottledCallback<F extends (...args: any[]) => any>(callback: F, wait = 200): Debounced<F> {
  const callbackRef = useRef(callback);
  useEffect(() => { callbackRef.current = callback; }, [callback]);
  const throttled = useMemo(() => throttle(((...args: Parameters<F>) => callbackRef.current(...args)) as F, wait), [wait]);
  useEffect(() => () => throttled.cancel(), [throttled]);
  return throttled;
}
hook 何を遅らせるか 向いている場面
useDebouncedValue 値(state) 入力欄の値で検索する、useEffect や データ取得ライブラリのキーに使う
useDebouncedCallback 関数の呼び出し onChange で保存 API を呼ぶ、flush/cancel も使いたい
useThrottledCallback 関数の呼び出し スクロール・ドラッグの処理

callbackRef に最新の関数を入れておくことで、debounce 関数自体は作り直さずに、常に最新の state を参照した処理を呼べます。 options をオブジェクトのまま依存配列に入れると毎回作り直しになるため、個々の値に分解しています。

使い方の例

import { useEffect, useState } from "react";
import { useDebouncedValue, useDebouncedCallback } from "./hooks";

export function ItemSearch() {
  const [text, setText] = useState("");
  const query = useDebouncedValue(text, 300);
  const [items, setItems] = useState<string[]>([]);

  useEffect(() => {
    if (!query) return;
    const ac = new AbortController();
    fetch(`/api/items?q=${encodeURIComponent(query)}`, { signal: ac.signal })
      .then((r) => r.json())
      .then(setItems)
      .catch(() => {});
    return () => ac.abort();  // 古い問い合わせの結果で上書きしない
  }, [query]);

  const saveMemo = useDebouncedCallback((memo: string) => {
    void fetch("/api/memo", { method: "PUT", body: memo });
  }, 1000, { maxWait: 5000 });

  return (
    <>
      <input value={text} onChange={(e) => setText(e.target.value)} placeholder="備品を検索" />
      <ul>{items.map((i) => <li key={i}>{i}</li>)}</ul>
      <textarea onChange={(e) => saveMemo(e.target.value)} onBlur={() => saveMemo.flush()} />
    </>
  );
}

(この使い方の例は書き方の参考で、API サーバーを用意しての実行はしていません。hook 自体の動作は次の検証で確認しています。)

検証:hook 版と「描画のたびに作り直す」書き方の違い

jsdom 30.1.1 上で React 19.3.0 を動かし、「ノ」「ノー」「ノート」と20ms間隔で入力したときの実行回数を比べました。

// src/hooks-test.ts(検証用。描画中に呼んでいるのは比較のためです)
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html><div id=root></div>");
Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true });
const { createElement: h, useState, act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { useDebouncedValue, useDebouncedCallback } = await import("./hooks.js");
const { debounce } = await import("./timing.js");
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

const fetched: string[] = [];
const naive: string[] = [];
let setText!: (s: string) => void;
function SearchBox() {
  const [text, _setText] = useState("");
  setText = _setText;
  const q = useDebouncedValue(text, 100);
  const save = useDebouncedCallback((v: string) => fetched.push(`cb:${v}`), 100);
  const bad = debounce((v: string) => naive.push(v), 100);   // 描画のたびに作り直してしまう例
  if (text) { save(text); bad(text); }
  if (q) fetched.push(`value:${q}`);
  return h("p", null, text);
}
const root = createRoot(document.getElementById("root")!);
await act(async () => root.render(h(SearchBox)));
for (const s of ["ノ", "ノー", "ノート"]) { await act(async () => setText(s)); await sleep(20); }
await act(async () => { await sleep(200); });
console.log("useDebouncedValue / useDebouncedCallback:", fetched);
console.log("描画のたびに debounce を作る:", naive);
await act(async () => root.unmount());
useDebouncedValue / useDebouncedCallback: [ 'cb:ノート', 'value:ノート' ]
描画のたびに debounce を作る: [ 'ノ', 'ノー', 'ノート' ]

hook 版は最後の「ノート」で1回だけ実行され、描画のたびに作り直した方は3回とも実行されました。 React の外でも同じことが起き、イベントのたびに debounce() を呼ぶと、間引きされずに入力の数だけ実行されます。

let wrong = 0, right = 0;
const onInputWrong = (v: string) => debounce(() => { wrong++; }, 50)();  // 毎回新しく作る
const handler = debounce(() => { right++; }, 50);                       // 1つを使い回す
const onInputRight = (v: string) => handler();
for (const ch of "ノートパソコン") { onInputWrong(ch); onInputRight(ch); }
await sleep(100);
console.log(`毎回作り直し: ${wrong}回 / 1つを使い回し: ${right}回`);
// 実行結果: 毎回作り直し: 7回 / 1つを使い回し: 1回

React でのよくある失敗と直し方

書き方 起きること 直し方
コンポーネント内で const f = debounce(...) 描画のたびに別の関数になり、間引きされない useDebouncedCallback / useMemo
useCallback(debounce(fn, 300), []) 動くが、fn が最初の描画の state を参照し続ける callbackRef で最新の関数を呼ぶ
useMemo(() => debounce(...), [options]) options が毎回新しいオブジェクトで作り直される 値に分解して依存配列へ
アンマウント後に実行される 閉じた画面の state を更新しようとする useEffect のクリーンアップで cancel()
入力欄の value 自体を debounce する 文字が遅れて表示される 表示用の state はすぐ更新し、検索用の値だけ遅らせる
古いリクエストの結果で上書き 遅れて返った結果が表示される AbortController で前の問い合わせを中止

lodash を使う場合との比較

lodash(lodash-es 4.18.1)の debounce / throttle は、この記事の実装と同じタイミングで実行されることを実測で確認しました。 どちらを選ぶかは、依存を増やしたいかどうかで決めて大丈夫です。

比較項目 自作(この記事の timing.ts) lodash-es
オプション leading / trailing / maxWait / cancel / flush / pending 同じ(pending は持たない)
型 実装に含む @types/lodash-es を追加
バンドルサイズ(esbuild 0.28.2・minify) 950 bytes(gzip 470 bytes) 3,135 bytes(gzip 1,501 bytes)
メリット 依存なし、中身を読んで調整できる 広く使われており、保守を任せられる
向いている場面 依存を増やしたくないライブラリ・小さなアプリ すでに lodash を使っている
// lodash-es から必要な関数だけ読み込む
import { debounce, throttle } from "lodash-es";
const onSearch = debounce((q: string) => console.log(q), 300, { maxWait: 1000 });
const onScroll = throttle(() => console.log(scrollY), 200, { trailing: true });

lodash の throttle も内部で debounce に maxWait: wait を渡す作りで、この記事の実装も同じ考え方です。 React 向けには use-debounce などの専用ライブラリもあり、hook を自作したくない場合の選択肢になります。


よくある質問

Q. debounce の待ち時間は何ミリ秒にすればよいですか? A. 検索入力なら 200〜400ms、自動保存なら 1000ms 前後がよく使われる目安です。短すぎると間引きの効果が小さく、長すぎると反応が遅く感じられるので、実際に操作して決めます。maxWait を付けると、呼ばれ続けても上限時間ごとに実行されます。

Q. throttle と debounce の leading の違いは何ですか? A. throttle は既定で leading: true なので、最初の呼び出しですぐ実行し、その後も一定間隔で実行します。debounce の leading: true, trailing: false は、最初の1回だけ実行し、呼び出しが wait 以上止まるまで次を受け付けません。ボタン連打の防止には後者が向いています。

Q. async 関数を debounce するとどうなりますか? A. 実行のタイミングは同じように間引かれますが、戻り値は「前回実行したときの Promise」になります。結果を待ちたい場合は、debounce した関数の戻り値を使わず、実行される関数の中で state を更新するか、AbortController で前の処理を中止する形にすると扱いやすくなります。

Q. テストでタイマーを待たずに確認するには? A. Vitest や Jest の偽タイマー(vi.useFakeTimers() など)で時間を進めて確認できます。この記事の実装は Date.now() と setTimeout を使っているので、両方を偽装するモードで動かします。


まとめ

  • debounce は「止まってから1回」、throttle は「一定間隔で1回」。50ms×21回の実測で 1回 と 7回
  • throttle は debounce に maxWait = wait を付けたものと同じ動きで、lodash とも実行タイミングが一致
  • 型付き TypeScript 実装で leading・trailing・maxWait・cancel・flush・this と引数の受け渡しに対応
  • 用途で選ぶ:検索・自動保存は debounce、スクロールは throttle、描画は requestAnimationFrame
  • 関数は1つを使い回す。毎回作り直すと 7回入力で 7回実行される
  • React は useDebouncedValue / useDebouncedCallback で、アンマウント時に cancel()
  • lodash を使うか自作かは依存の方針で選んでよい(自作は gzip 470 bytes)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「JavaScript debounce・throttle|違いとTS実装をコピペ解説」

株式会社RJCでは、イベント処理の工夫やフロントエンドの設計を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?