
はじめに
この記事では、lodash (lodash.debounce) に依存しない、 React 向けの debounce のカスタムフックを自前で実装する方法を説明します。
これにより、アプリケーションのバンドルサイズ削減や外部ライブラリへの依存性低減といった効果が期待できます。
debounce とは
debounce とは、頻繁に発生するイベントに対して、処理の実行回数を制限するためのテクニックです。
連続してイベントが発生している間は処理を遅延させ、イベントが一定時間発生しなくなってから処理を実行します。
例えば、検索ボックスへの文字入力ごとに API リクエストを送信すると、無駄なリクエストが大量に発生し、サーバー負荷や UI のパフォーマンス低下を招く可能性があります。
debounce を適用することで、入力が停止してから一度だけ API リクエストを送信するなど、不要な処理の実行を防ぎ、パフォーマンス向上に繋がります。
lodash の debounce には以下のような便利なオプションが提供されており1、より柔軟な動作制御が可能です。
-
leading:trueに設定すると、イベントが発生した直後に一度だけ関数を実行します (デフォルトはfalse) -
trailing:trueに設定すると、連続するイベントがwaitミリ秒以上発生しなくなってから、最後に一度だけ関数を実行します (デフォルトはtrue) -
maxWait: 関数がdebounceによって遅延される最大時間を設定します- 例えば、
waitが 1000ms でも、maxWaitが 5000ms であれば、どんなにイベントが連続しても 5000ms 以内には必ず一度実行されます
- 例えば、
この記事では、これらのオプションの中から leading と trailing の動作を含めて自前で実装を進めていきます。maxWait オプションについては、今回の実装の範囲外とします。
シンプルな debounce の実装
まずは、各オプションの対応は考えずに、シンプルな debounce を実装していきます。
シンプルな debounce で期待する動作は、leading が false かつ trailing が true 場合の動作です。
つまり、とりあえず連続で呼び出されている間は更新がされず、一定以上間が空いたら必ず更新されるような動作です。
カスタムフックの定義
まずは、カスタムフックの定義を以下のようにします。
export const useDebounce = T, U extends any[]>(
func: (...args: U) => T,
args: U,
wait: number = 0
): T | undefined => {
// ...
};
-
: これによって、任意の引数・戻り値の関数へ対応できるようにしています -
func: debounce したい関数を渡します -
args:funcに渡す引数の配列を渡します- 実質的にこのカスタムフックの依存配列として機能します (
useEffect等の依存配列と同じように扱われます)
- 実質的にこのカスタムフックの依存配列として機能します (
-
wait: 遅延させる時間(ミリ秒)を渡します -
T | undefined:funcの実行結果を返します-
funcが最初に呼び出されるまでは、undefinedとなるようにしておきます
-
内部状態の管理
debounce の処理の間、保持する必要がある情報を useState や useRef といった hooks で管理します。
const [result, setResult] = useStateT | undefined>(undefined);
const timeoutRef = useRefReturnTypetypeof 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.current を null に設定する処理です。
この関数を 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] = useStateT | undefined>(undefined);
const timeoutRef = useRefReturnTypetypeof 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>
);
};
入力されている文字に応じて「Input Value: 」の部分はリアルタイムに更新されていますが、「Debounce Value: 」の部分は入力停止後 1000ms 経過してから更新されていることが分かります。
leading と trailing の追加
ここからが本題です。
leading と trailing の挙動について
leading と trailing は真偽値で、その組み合わせによって、以下の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] = useStateT | undefined>(undefined);
const timeoutRef = useRefReturnTypetypeof 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);
leading が true で、かつ leadingCalledRef.current が false(つまり、今回のイベントが連続するイベント群の最初のイベントである)ならば、func がすぐに実行され、その結果が result にセットされます。
その後、leadingCalledRef.current は true に設定され、この一連のイベント中は先頭での実行が一度だけ行われるように制御されます。
また、trailing が true であれば、wait ミリ秒経過した最後に func が実行され、結果が result にセットされます。
そして、leading が true であれば、ここで leadingCalledRef.current が false にリセットされます。
これにより、現在のイベントシーケンスの終了が分かり、次のイベントシーケンスが開始された際には、再び 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] = useStateT | undefined>(undefined);
const timeoutRef = useRefReturnTypetypeof 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 の場合
この設定では、debounce された関数は実行されません。
文字を入力しても、「Debounce value: 」は初期値のまま更新されないことが確認できます。
leading: false, trailing: true の場合
この設定は、シンプルな debounce と同じ挙動です。
文字の入力中は、「Debounce value: 」の更新は行われませんが、入力停止後 1000ms 経過すると、最終的な入力値で「Debounce value: 」が更新されます。
leading: true, trailing: false の場合
この設定では、文字を入力し始めた直後に一度だけ「Debounce value: 」が更新されます。
その後の連続入力や入力停止後の更新はありません。
ただし、入力停止後 1000ms が経過すると、leadingCalledRef フラグがリセットされるため、その後の最初の入力で関数が再度実行されます。
leading: true, trailing: true の場合
この設定では、文字を入力し始めた直後に一度「Debounce value: 」が更新されます。
また、入力停止後 1000ms 経過すると、最終的な入力値で再度「Debounce value: 」が更新されます。
おわりに
この記事では、lodash に依存せず、React 環境で利用可能な debounce のカスタムフックを自前で実装する方法について説明しました。
シンプルな debounce の基本から始め、leading とtrailing といったオプションの実装まで段階的に紹介しています。
この他に、lodash の debounce には maxWait オプションもありますが、これの実装を含めると複雑さが増すため、この記事ではスコープ外としました。
興味があればぜひ実装してみてください。
Views: 0





