デバウンスとスロットルの使い分け

デバウンスとスロットルの使い分け

作成日:
読了:7
更新日:

検索ボックスの入力やスクロールは、1回の操作で何十回もハンドラを呼びます。そのたびにAPIを叩いたりレイアウトを測ったりすると、通信も描画も詰まります。呼び出しの回数を減らす方法が2つあり、抑制のしかたが異なります。

デバウンス:最後の呼び出しから一定時間が過ぎて初めて、1回だけ実行します。 スロットル:一定間隔ごとに最大1回だけ実行します。

デバウンスは「呼び出しが止まるのを待つ」動きです。連続入力が落ち着いてから動くので、検索のように最終結果だけが欲しい処理に合います。スロットルは「動き続けても間隔をあけて実行を続ける」動きです。スクロール追従のように、途中経過も一定の粒度で反映したい処理に合います。MDNとlodashはこの2つを別の用語として定義しており、混同すると意図しない回数だけ処理が走ります。

デバウンスが向く場面

入力が一区切りつくまで待ってよい処理が当てはまります。インクリメンタル検索では、文字を打つたびに問い合わせる代わりに、手が止まってから検索すれば通信が1回で済みます。ウィンドウのリサイズに応じたレイアウト再計算や、入力欄のバリデーションも、操作の完了後に1回動けば足ります。

スロットルが向く場面

途中経過を一定間隔で反映したい処理が当てはまります。スクロール位置に連動してヘッダーを出し入れするUIや、マウス移動への追従は、毎フレーム動かす必要はありませんが、止まるまで待つわけにもいきません。無限スクロールの末尾検出も、一定間隔で位置を確かめれば十分に間に合います。

処理向く方式理由
検索ボックスの逐次問い合わせデバウンス入力が止まってから1回だけ叩く
リサイズ後の再計算デバウンス完了後に1回処理すればよい
スクロール連動UIスロットル一定間隔で更新すれば足りる
マウス追従スロットル定期的な位置取得で十分
無限スクロールの末尾検出スロットル間隔をあけて位置を確認する

素のJavaScriptでの実装

デバウンスは、呼ばれるたびに前のタイマーを取り消し、新しいタイマーを張り直します。最後の呼び出しからwaitが経過したときだけコールバックが残ります。

debounce
function debounce(fn, wait) {
  let timerId;
  return function (...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => fn.apply(this, args), wait);
  };
}

スロットルは、前回の実行時刻を覚えておき、waitが過ぎていれば実行します。

throttle
function throttle(fn, wait) {
  let lastTime = 0;
  return function (...args) {
    const now = Date.now();
    if (now - lastTime >= wait) {
      lastTime = now;
      fn.apply(this, args);
    }
  };
}

leadingとtrailingで発火位置が変わる

実行のタイミングには、抑制区間の入口で動く「leading」と、出口で動く「trailing」があります。デバウンスの既定はtrailingだけで、入力が静まった後に1回動きます。スロットルの既定はleadingとtrailingの両方で、最初にすぐ動き、区間の終わりにもう一度動きます。

ここを取り違えると、押した瞬間に動いてほしいのに反応が遅れたり、逆に1回でよい処理が2回走ったりします。「最初の1回だけ即座に動かしたい」ボタンの連打防止なら、leadingを有効にしてtrailingを切ります。

lodashを使う場合

leadingとtrailingを両立させる実装は、状態管理が増えて間違えやすいものです。lodashの_.debounce_.throttleは、leadingtrailingmaxWaitをオプションで受け取り、戻り値にcancelflushを備えています。

maxWaitは、呼び出しが途切れない間も「ここまで待ったら1回は実行する」上限を決めます。lodashのドキュメントは、スロットルをmaxWait付きのデバウンスとして説明しています。両者は別物というより、抑制ルールの違いとして地続きになっています。

lodash
import debounce from 'lodash/debounce';
 
const search = debounce(query => fetchResults(query), 300, {
  leading: false,
  trailing: true,
  maxWait: 1000,
});

Reactで作り直しを防ぐ

Reactでデバウンスを使うとき、レンダーのたびに新しいデバウンス関数を作ると、タイマーが毎回リセットされて抑制が効きません。

効かない例
function SearchBox() {
  // レンダーごとに別物が生成され、待機がリセットされる
  const handleChange = debounce(fn, 300);
}

関数を1つに保つには、useMemoで生成を固定します。固定すると今度は、生成時点のstateやpropsを閉じ込めたまま古い値で動くことがあります。最新の処理をuseRefに持たせ、デバウンス関数からはその参照を呼ぶと、固定と最新参照を両立できます。

遅延の最中にコンポーネントが消えると、wait後の実行が外れたstateを触ります。useEffectのクリーンアップでcancelを呼び、保留中の実行を取り消します。

安定化とクリーンアップ
const debounced = useMemo(() => debounce(fn, 300), []);
useEffect(() => () => debounced.cancel(), [debounced]);

なお、古い解説に出てくるevent.persist()はReact 17でイベントのプールが廃止されたため不要になりました。現在のReactでは、デバウンス内でイベントオブジェクトを後から参照しても無効化されません。

スクロール位置を読んでDOMを更新する処理に限れば、requestAnimationFrameがスロットルの代わりになります。描画の直前に1回だけ実行されるため、無駄な再計算を避けられます。ただし実行間隔はディスプレイのリフレッシュレートに依存し、60Hzでおよそ16msになります。

参考

Next.js 16 の Cache Components と PPR - キャッシュを「明示的」に組み直す

Next.js 16 の Cache Components と PPR - キャッシュを「明示的」に組み直す

11

Next.js 16 の Cache Components(cacheComponents)と Partial Prerendering(PPR)を整理します。暗黙的なキャッシュをやめ、use cache ディレクティブで「キャッシュするものだけ明示する」設計への転換、静的シェル+動的ストリーミングという PPR の考え方、cacheLife / cacheTag / revalidateTag / updateTag の使い分け、そして v15 から 16 への移行で詰まりやすい点を、公式ドキュメントをもとにコード例つきでまとめます。