3
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 の throttle と同程度の機能を備えた React 用のカスタムフックを自前実装する

Posted at

はじめに

この記事では、lodash (lodash.throttle) に依存しない、React 向けの throttle のカスタムフックを自前で実装する方法を説明します。
以下の記事の throttle バージョンです。

throttle とは

throttle とは、頻繁に発生するイベントに対して、一定時間内に一度だけ処理を実行するためのテクニックです。
連続してイベントが発生しても、指定された時間間隔ごとで処理を実行します。

throttle と似た概念に、以前の記事で実装した debounce があります。
両者の違いは、処理を実行するタイミングです。

  • debounce: 連続するイベントが終了してから、一度だけ処理を実行
  • throttle: 連続するイベント中に、一定時間ごとに処理を実行

このように、debounce は「最後のイベント」に、throttle は「時間間隔」に注目している点が大きな違いです。

さて、lodashthrottle には、debounce と同様に leadingtrailing のオプションが提供されています 1

  • leading: true に設定すると、イベントが発生した直後に一度だけ関数を実行します (デフォルトは true)
  • trailing: true に設定すると、連続するイベントが終了した後に、最後に一度だけ関数を実行します (デフォルトは true)

この記事では、leadingtrailing の動作を含めて自前で実装を進めていきます。

シンプルな throttle の実装

まずは、各オプションの対応は考えずに、シンプルな throttle を実装していきます。

この記事での、シンプルな throttle で期待する動作は、leadingtrue かつ trailingfalse の場合の動作です。
これは、関数が連続で呼び出されている間は、一定時間ごとに実行されるという動作です。
また、trailingfalse のため、最後の呼び出しで更新されることは保証されません。
具体的には、最後の呼び出しがたまたま一定時間の区切りの最初であれば更新されますが、それ以外の場合は無視されるという動作になります。

カスタムフックの定義

まずは、カスタムフックの定義を以下のようにし、任意の引数・戻り値の関数へ対応できるようにします。

export const useThrottle = <T, U extends any[]>(
  func: (...args: U) => T,
  args: U,
  wait: number = 0
): T | undefined => {
  // ...
};
  • <T, U extends any[]>: 任意の引数・戻り値の関数へ対応
  • func: throttle したい関数
  • args: func に渡す引数の配列(依存配列として機能)
  • wait: 処理の実行間隔(ミリ秒)
  • T | undefined: func の実行結果

内部状態の管理

throttle の処理で保持する必要がある情報を管理するために、useStateuseRef を使用します。

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

throttle 処理の実装

useEffect を使うことで、引数 args の変更を監視し、wait ミリ秒が経過するたびに func を実行します。

useEffect(() => {
  if (!timeoutRef.current) {
    setResult(func(...args));
    timeoutRef.current = setTimeout(() => {
      timeoutRef.current = null;
    }, wait);
  }
}, [...args]);

// アンマウント時にタイマーをクリアするための useEffect
useEffect(() => {
  return () => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
      timeoutRef.current = null;
    }
  };
}, []);

一つ目の useEffect の中で行われている処理は、与えられた関数の実行と、実行から wait ミリ秒経っているかどうかのフラグ管理です。

timeoutRef.current にタイマーの情報が存在しない(直近で func が実行されておらず、wait ミリ秒経過している)場合のみ、func を実行して result を更新するようにします。
その後、新しくタイマーをセットし、wait ミリ秒後にタイマーの情報をクリアします。
このタイマーが存在する間は、func が再度実行されることはありません。

これらの処理により、イベントが連続して発生しても、wait 時間ごとに一度だけ処理が実行されるシンプルな throttle が実装できます。

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

export const useThrottle = <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);

  useEffect(() => {
    if (!timeoutRef.current) {
      setResult(func(...args));
      timeoutRef.current = setTimeout(() => {
        timeoutRef.current = null;
      }, wait);
    }
  }, [...args]);

  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
        timeoutRef.current = null;
      }
    };
  }, []);

  return result;
};

挙動の確認

シンプルな throttle の挙動を確認してみます。

const App = () => {
  const [value, setValue] = useState("");
  const [throttleValue, setThrottleValue] = useState("");

  useThrottle(setThrottleValue, [value], 1000);

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

20250813-1359-49.7639847.gif

入力されている文字に応じて「Input value: 」はリアルタイムに更新されますが、「Throttle value: 」は入力開始直後と、その後 1000ms ごとに更新されていることがわかります。
また、入力が終了後に待っても、最後の文字は反映されないことも分かります。

leading と trailing の追加

ここからが本題で、leadingtrailing オプションを追加して、lodashthrottle に近い挙動を実装します。

leading と trailing の挙動について

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

  • leading: false, trailing: false の場合
    • 更新は一切行われません
  • leading: false, trailing: true の場合
    • イベントが発生し続ける限り、一定時間 (wait ミリ秒) ごとに一度、その期間の最後に関数を実行します
  • leading: true, trailing: false の場合
    • 最初に一度実行され、その後はイベントが続く限り、一定時間 (wait ミリ秒) ごとに一度、その期間の最初に関数を実行します
      • 先程のシンプルな throttle の実装と同じ挙動になります
  • leading: true, trailing: true の場合
    • 最初の呼び出しで一度実行され、その後もイベントが続く限り一定時間 (wait ミリ秒) ごとに関数を実行します
    • これにより、最初と最後の更新が両方とも保証されます

カスタムフックの定義

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

まず、オプションの型を定義し、カスタムフックの定義に options を追加します。

type ThrottleOptions = {
  leading?: boolean;
  trailing?: boolean;
};
 export const useThrottle = <T, U extends any[]>(
   func: (...args: U) => T,
   args: U,
   wait: number = 0,
+  options: ThrottleOptions = {}
 ): T | undefined => {

内部状態の管理

最新の args の値を参照できるように useRef で管理します。
また、options のデフォルト値もここで設定しておきます。

  const [result, setResult] = useState<T | undefined>(undefined);
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+ const lastArgs = useRef<U | null>(null);

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

throttle 処理の実装

useEffect 内で leadingtrailing の真偽値に応じて処理を分岐させます。

  if (!timeoutRef.current) {
-   setResult(func(...args));
+   if (leading) setResult(func(...args));
+   else if (trailing) lastArgs.current = args;

    timeoutRef.current = setTimeout(() => {
+     if (trailing && lastArgs.current) {
+       setResult(func(...lastArgs.current));
+       lastArgs.current = null;
+     }
      timeoutRef.current = null;
    }, wait);
+ } else {
+   lastArgs.current = args;
  }

シンプルな throttle の際に説明した通り、timeoutRef.current が存在しない場合は、wait ミリ秒が経過した直後か、最初の呼び出しであることを示しています。

まず、leadingtrue であれば、func をすぐに実行します。
そうでなく、trailingtrue である場合、args の内容を lastArgs に保存することで、trailing の処理に備えておきます。

その後、タイマーをセットし、wait ミリ秒後に trailing の処理を行えるようにします。

また、timeoutRef.current が存在する(wait ミリ秒以内に連続してイベントが発生している)場合は、trailing の処理に備えて lastArgs.current に現在の引数 args を保存します。

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

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

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

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

  useEffect(() => {
    if (!timeoutRef.current) {
      if (leading) setResult(func(...args));
      else if (trailing) lastArgs.current = args;

      timeoutRef.current = setTimeout(() => {
        if (trailing && nextArgs.current) {
          setResult(func(...lastArgs.current));
          lastArgs.current = null;
        }
        timeoutRef.current = null;
      }, wait);
    } else {
      lastArgs.current = args;
    }
  }, [...args]);

  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
        timeoutRef.current = null;
      }
    };
  }, []);

  return result;
};

挙動の確認

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

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

leading: false, trailing: false の場合

20250813-1603-13.3795607.gif

この設定では、throttle された関数は実行されません。
「Throttle value: 」は初期値のまま更新されないことが確認できます。

leading: false, trailing: true の場合

20250816-0946-25.2514033.gif

この設定では、wait ミリ秒経過ごとに「Throttle value: 」が更新されます。
入力の最初では、更新はされません。

leading: true, trailing: false の場合

20250813-1619-36.5343036.gif

この設定は、シンプルな throttle と同じ挙動です。
入力開始直後と、その後 wait ミリ秒ごとに「Throttle value: 」が更新されます。
入力停止後には、更新はされません。

leading: true, trailing: true の場合

20250813-1616-09.4191455.gif

入力開始直後と、その後 wait ミリ秒ごとに「Throttle value: 」が更新されます。
さらに、入力が停止したあとにも、最後に発生したイベントの値で最終的な更新が行われます。

おわりに

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

debounce と throttle は似ていますが、下記のようにユースケースが異なります。

  • debounce: ユーザーが入力し終えた後に、APIコールを実行するようなケース
  • throttle: スクロールイベントのように、一定間隔で処理を実行したいケース

これらを状況に応じて使い分けることで、よりパフォーマンスの高いアプリケーションを構築できます。

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

3
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
3
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?