10
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

この記事では、lodash (lodash.debounce) に依存しない、 React 向けの debounce のカスタムフックを自前で実装する方法を説明します。
これにより、アプリケーションのバンドルサイズ削減や外部ライブラリへの依存性低減といった効果が期待できます。

debounce とは

debounce とは、頻繁に発生するイベントに対して、処理の実行回数を制限するためのテクニックです。
連続してイベントが発生している間は処理を遅延させ、イベントが一定時間発生しなくなってから処理を実行します。

例えば、検索ボックスへの文字入力ごとに API リクエストを送信すると、無駄なリクエストが大量に発生し、サーバー負荷や UI のパフォーマンス低下を招く可能性があります。
debounce を適用することで、入力が停止してから一度だけ API リクエストを送信するなど、不要な処理の実行を防ぎ、パフォーマンス向上に繋がります。

lodashdebounce には以下のような便利なオプションが提供されており1、より柔軟な動作制御が可能です。

  • leading: true に設定すると、イベントが発生した直後に一度だけ関数を実行します (デフォルトは false)
  • trailing: true に設定すると、連続するイベントが wait ミリ秒以上発生しなくなってから、最後に一度だけ関数を実行します (デフォルトは true)
  • maxWait: 関数が debounce によって遅延される最大時間を設定します
    • 例えば、wait が 1000ms でも、maxWait が 5000ms であれば、どんなにイベントが連続しても 5000ms 以内には必ず一度実行されます

この記事では、これらのオプションの中から leadingtrailing の動作を含めて自前で実装を進めていきます。
maxWait オプションについては、今回の実装の範囲外とします。

シンプルな debounce の実装

まずは、各オプションの対応は考えずに、シンプルな debounce を実装していきます。
シンプルな debounce で期待する動作は、leadingfalse かつ trailingtrue 場合の動作です。
つまり、とりあえず連続で呼び出されている間は更新がされず、一定以上間が空いたら必ず更新されるような動作です。

カスタムフックの定義

まずは、カスタムフックの定義を以下のようにします。

export const useDebounce = <T, U extends any[]>(
  func: (...args: U) => T,
  args: U,
  wait: number = 0
): T | undefined => {
  // ...
};
  • <T, U extends any[]>: これによって、任意の引数・戻り値の関数へ対応できるようにしています
  • func: debounce したい関数を渡します
  • args: func に渡す引数の配列を渡します
    • 実質的にこのカスタムフックの依存配列として機能します (useEffect 等の依存配列と同じように扱われます)
  • wait: 遅延させる時間(ミリ秒)を渡します
  • T | undefined: func の実行結果を返します
    • func が最初に呼び出されるまでは、undefined となるようにしておきます

内部状態の管理

debounce の処理の間、保持する必要がある情報を useStateuseRef といった hooks で管理します。

const [result, setResult] = useState<T | undefined>(undefined);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  • result: debounce された関数 func の最新の実行結果を保持する State です
    • func が最初に呼び出されるまでは、結果が存在しないため undefined を返すようにしています
  • timeoutRef: setTimeout の ID を保持するための Ref です
    • timeoutRef の変更によって再レンダリングされないように useState ではなく useRef を利用しています

debounce 処理の実装

useEffect を使うことで、引数 args の変更を監視し、その変更が検知されたタイミングで debounce 処理をリセットできます。
具体的には、args が変更されるたびに既存のタイマーをクリアし、新しいタイマーをセットすることで、debounce を実装します。

const clearCurrentTimeout = () => {
  if (timeoutRef.current) {
    clearTimeout(timeoutRef.current);
    timeoutRef.current = null;
  }
};

useEffect(() => {
  clearCurrentTimeout();
  timeoutRef.current = setTimeout(() => {
    setResult(func(...args));
  }, wait);

  return clearCurrentTimeout;
}, [...args]);

useEffect の中で行われている処理は、既存のタイマーのリセットと、新しいタイマーのセットです。

clearCurrentTimeout 関数は、もし現在のタイマーが存在すればそれをクリアし、timeoutRef.currentnull に設定する処理です。
この関数を useEffect の冒頭で呼び出すことで、新しいタイマーをセットする前に既存のタイマーが確実にキャンセルされるようにしています。

そして、args の変更が検知されるたびに、新しいタイマーをセットします。
この新しいタイマーは、wait ミリ秒後に func を実行し、その結果を result にセットするものです。

また、useEffect のクリーンアップ処理で、clearCurrentTimeout を呼び出すことで、コンポーネントがアンマウントされる際や、依存配列が変更されて新しい副作用が実行される前に、タイマーが適切にクリアされるようにしています。

全体のコード
import { useEffect, useRef, useState } from "react";

export const useDebounce = <T, U extends any[]>(
  func: (...args: U) => T,
  args: U,
  wait: number = 0,
): T | undefined => {
  const [result, setResult] = useState<T | undefined>(undefined);
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  const clearCurrentTimeout = () => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
      timeoutRef.current = null;
    }
  };

  useEffect(() => {
    clearCurrentTimeout();
    timeoutRef.current = setTimeout(() => {
      setResult(func(...args));
    }, wait);

    return clearCurrentTimeout;
  }, [...args]);

  return result;
};

挙動の確認

実際に利用するコードを試して、挙動の確認をしてみます。

const App = () => {
  const [value, setValue] = useState("");
  const [debounceValue, setDebounceValue] = useState("");

  useDebounce(setDebounceValue, [value], 1000);

  return (
    <div>
      <input onChange={(e) => setValue(e.target.value)} />
      <p>Input value: {value}</p>
      <p>Debounce value: {debounceValue}</p>
    </div>
  );
};

simple_debounce.gif

入力されている文字に応じて「Input Value: 」の部分はリアルタイムに更新されていますが、「Debounce Value: 」の部分は入力停止後 1000ms 経過してから更新されていることが分かります。

leading と trailing の追加

ここからが本題です。

leading と trailing の挙動について

leadingtrailing は真偽値で、その組み合わせによって、以下の4通りの挙動が期待されます。

  • leading: false, trailing: false の場合
    • 更新は一切行われません
  • leading: false, trailing: true の場合
    • 連続するイベントの終了後に、一度だけ関数を実行します
      • 先程のシンプルな debounce の実装と同じ挙動になります
  • leading: true, trailing: false の場合
    • 連続するイベントの開始時に、一度だけ関数を実行します
  • leading: true, trailing: false の場合
    • 連続するイベントの開始時と終了後に、それぞれ一度ずつ関数を実行します

カスタムフックの定義

ここから実装を書いていきますが、シンプルな debounce からの変更点のみを書きます。

まず、オプションの型を定義しておきます。

type DebounceOptions = {
  leading: boolean;
  trailing: boolean;
};

そして、カスタムフックの定義を以下のように変更します。

  export const useDebounce = <T, U extends any[]>(
    func: (...args: U) => T,
    args: U,
    wait: number = 0,
+   options: DebounceOptions = {}
  ): T | undefined => {

内部状態の管理

leading の処理がすでにされたかどうかを useRef で管理します。
また、options のデフォルトの値もここで設定します。

  const [result, setResult] = useState<T | undefined>(undefined);
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+ const leadingCalledRef = useRef(false);
+ 
+ const { leading = false, trailing = true } = options;

debounce 処理の実装

leading および trailing の真偽値によって、debounce 処理の実行タイミングを分岐させます。

  clearCurrentTimeout();

+ if (leading && !leadingCalledRef.current) {
+   setResult(func(...args));
+   leadingCalledRef.current = true;
+ }

  timeoutRef.current = setTimeout(() => {
-   setResult(func(...args));
+   if (trailing) setResult(func(...args));
+   if (leading) leadingCalledRef.current = false;
  }, wait);

leadingtrue で、かつ leadingCalledRef.currentfalse(つまり、今回のイベントが連続するイベント群の最初のイベントである)ならば、func がすぐに実行され、その結果が result にセットされます。
その後、leadingCalledRef.currenttrue に設定され、この一連のイベント中は先頭での実行が一度だけ行われるように制御されます。

また、trailingtrue であれば、wait ミリ秒経過した最後に func が実行され、結果が result にセットされます。

そして、leadingtrue であれば、ここで leadingCalledRef.currentfalse にリセットされます。
これにより、現在のイベントシーケンスの終了が分かり、次のイベントシーケンスが開始された際には、再び leading による先頭での実行が可能になります。

全体のコード
import { useEffect, useRef, useState } from "react";

type DebounceOptions = {
  leading?: boolean;
  trailing?: boolean;
};

export const useDebounce = <T, U extends any[]>(
  func: (...args: U) => T,
  args: U,
  wait: number = 0,
  options: DebounceOptions = {}
): T | undefined => {
  const [result, setResult] = useState<T | undefined>(undefined);
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const leadingCalledRef = useRef(false);

  const { leading = false, trailing = true } = options;

  const clearCurrentTimeout = () => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
      timeoutRef.current = null;
    }
  };

  useEffect(() => {
    clearCurrentTimeout();

    if (leading && !leadingCalledRef.current) {
      setResult(func(...args));
      leadingCalledRef.current = true;
    }

    timeoutRef.current = setTimeout(() => {
      if (trailing) setResult(func(...args));
      if (leading) leadingCalledRef.current = false;
    }, wait);

    return clearCurrentTimeout;
  }, [...args]);

  return result;
};

挙動の確認

シンプルな debounce の挙動の確認の際に利用したコードの、useDebounce の部分にオプションを追加して、それぞれの挙動を確認します。

useDebounce(setDebounceValue, [value], 1000, {
  // 各ケースでこの部分を変更して試す
  leading: false,
  trailing: false,
});

leading: false, trailing: false の場合

leading_false_trailing_false.gif

この設定では、debounce された関数は実行されません。
文字を入力しても、「Debounce value: 」は初期値のまま更新されないことが確認できます。

leading: false, trailing: true の場合

leading_false_trailing_true.gif

この設定は、シンプルな debounce と同じ挙動です。
文字の入力中は、「Debounce value: 」の更新は行われませんが、入力停止後 1000ms 経過すると、最終的な入力値で「Debounce value: 」が更新されます。

leading: true, trailing: false の場合

leading_true_trailing_false.gif

この設定では、文字を入力し始めた直後に一度だけ「Debounce value: 」が更新されます。
その後の連続入力や入力停止後の更新はありません。
ただし、入力停止後 1000ms が経過すると、leadingCalledRef フラグがリセットされるため、その後の最初の入力で関数が再度実行されます。

leading: true, trailing: true の場合

leading_true_trailing_true.gif

この設定では、文字を入力し始めた直後に一度「Debounce value: 」が更新されます。
また、入力停止後 1000ms 経過すると、最終的な入力値で再度「Debounce value: 」が更新されます。

おわりに

この記事では、lodash に依存せず、React 環境で利用可能な debounce のカスタムフックを自前で実装する方法について説明しました。

シンプルな debounce の基本から始め、leading とtrailing といったオプションの実装まで段階的に紹介しています。
この他に、lodashdebounce には maxWait オプションもありますが、これの実装を含めると複雑さが増すため、この記事ではスコープ外としました。
興味があればぜひ実装してみてください。

  1. https://lodash.com/docs/#debounce

10
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?