はじめに
この記事では、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