🔎

debounce 処理を利用してリアルタイム検索機能を改善してみた

2024/01/02に公開

はじめに

実務でインクリメンタルサーチ機能(=リアルタイム検索)のパフォーマンスを改善するために 「debounce 処理」(後述します)を実装する必要がありましたので、工夫した点などについて書いていこうと思います。

興味ある方はぜひ最後までご覧ください。

debounce とは?

まず最初に「debounce」について軽く説明しておきます。

debounce とは 「対象のイベントが発生してから指定した時間が経過するまでは、同じイベントの発生を抑制する仕組み」 です。

もっとシンプルに言うと、対象のイベントにより連続で実行された関数たちの中で 1 番最後の関数だけ実行させる..といったイメージでしょうか?
(下記だと 3 と 6 だけしか実行されない)

言葉だけではイメージをつかめない方は、以下のサンプルコードを実際に動かしてみてください。

これまでのリアルタイム検索処理と課題

さてここからが本題です。

まず前提としてこのリアルタイム検索機能は、検索処理自体はフロントエンドで行わずにバックエンドの検索用 API を経由して行うようになっています。

具体的には以下のように

  1. ユーザーが入力する
  2. onChange イベントでアドレスバーのクエリパラメータを更新
  3. クエリパラメータに基づいて検索処理用の API を呼び出す
  4. 取得した情報を元に検索結果を描画

という流れで行っています。

ただこれをそのまま愚直に実行してしまうと、ユーザー入力のたびに API を呼び出してしまうことになり、通信負荷がかかってしまうという問題点があります。

ここで欲しくなるのが、先ほど説明した debounce 処理です。

ちょうど 2 の部分に debounce をかけることで、onChangeイベントの入力が終わった後にのみ 3 の API 呼び出しを実行できるようになり、検索処理を大幅に効率化することができます。

debounce 処理の実装

debounce 処理の実装方法としては react-use や usehooks-ts に代表される useDebounce、あるいは Mantine UI の useDebounceValue といった組み込みの hooks を用いるという手があります。

react-use
usehooks-ts
Mantine UI

例えば usehooks-ts のuseDebounceは以下のように用います。

サンプルコード
import { ChangeEvent, useEffect, useState } from "react";

import { useDebounce } from "usehooks-ts";

export default function Component() {
  const [value, setValue] = useState<string>("");
  const debouncedValue = useDebounce<string>(value, 500);

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    setValue(event.target.value);
  };

  // Fetch API (optional)
  useEffect(() => {
    // Do fetch here...
    // Triggers when "debouncedValue" changes
  }, [debouncedValue]);

  return (
    <div>
      <p>Value real-time: {value}</p>
      <p>Debounced value: {debouncedValue}</p>

      <input type="text" value={value} onChange={handleChange} />
    </div>
  );
}

使い方としては非常にシンプルで、「ユーザーの入力を反映する値」「遅延させたい秒数(ms)」 を引数として渡すだけです。

ただこの場合 「値を渡したら値が返ってくる」 という構造になってしまっており、今回のような 「アドレスバーのクエリパラメータを更新する処理」のみを debounce させたいケースにはマッチしません。
useDebounceの中身を見てもらっても、実態としてはただ値の受け渡しを行っているだけであることがわかると思います。)

useDebounceの中身
import { useEffect, useState } from "react";

export function useDebounce<T>(value: T, delay?: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay || 500);

    return () => {
      clearTimeout(timer);
    };
  }, [value, delay]);

  return debouncedValue;
}

というわけで、今回はこのカスタムフックを自作してコールバック関数を渡せるように変更を加え、「処理」を debounce させる場合にも対応できるようにしましょう。

実装例は以下になります。

作成したカスタムフック
import { useCallback, useRef } from "react";

type Debounce = (fn: () => void) => void;

type DebounceReturn = {
  // debounceさせたい処理をラップする関数
  debounce: Debounce;
  // 即座に実行する用の関数であるflushも定義
  flush: () => void;
};

export const useDebounce = (timeout: number): DebounceReturn => {
  // useRefを用いてタイマーIDをレンダリング間で保持している
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
  // useRefを用いて最新のdebounceさせたい処理をレンダリング間で保持している
  const debounceFn = useRef<() => void>(() => {});

  const debounce: Debounce = useCallback(
    (fn) => {
      debounceFn.current = fn;
      if (timer.current) {
        clearTimeout(timer.current);
      }
      timer.current = setTimeout(() => {
        debounceFn.current();
        debounceFn.current = () => {};
      }, timeout);
    },
    [timeout]
  );

  // debounceの遅延を待たずに直ちに実行するためのもの
  const flush = useCallback(() => {
    if (timer.current) {
      clearTimeout(timer.current);
    }
    debounceFn.current();
  }, [timer.current, debounceFn.current]);

  return { debounce, flush };
};

ポイントとしてはuseRefでミュータブルなオブジェクトを作成しているところでしょうか。
以下ドキュメントにもある通り、「タイムアウト ID の保存」のような 「コンポーネントが値を保存する必要があるがそれがレンダーロジックに影響しないケース」 ではuseRefを用いることが推奨されています。

https://ja.react.dev/learn/referencing-values-with-refs#when-to-use-refs

今回の debounce 処理に登場するtimerはまさにそのようなケースに該当し、コンポーネントがアンマウントされるまで同じものを参照できるようにしています。


また、flushという関数は debounce の遅延を待たずに直ちに処理を実行させるために利用するためのものです。

例えば、ユーザーがテキスト入力を行っていて、その入力を debounce してサーバー側に送信する場合を考えてみましょう。
ユーザーが入力を停止したときに自動的にデータを送信するためには debounce を使用できますが、ユーザーが明示的に「送信」ボタンを押した場合は、debounce の遅延を待たずに直ちにデータを送信したいでしょう。そのような場合に flush 関数を使用して、debounce された関数を直ちに実行します。

実際の実装例

最後に上記で作成したカスタムフックの使用例をサンプルコードとともに載せておきます。
(実際の実装内容と近くなるようにしています。)

処理の流れとしては以下のようなイメージです。

useDebounceの使い方は非常にシンプルです。
予めtimeoutをもらっておいて、あとは使いたい関数をラップするだけです。

ここでは、setSearchParamsというクエリパラメータを更新する setter 関数をラップしています。

Page.tsx
export default function Page() {
  const [searchParams, setSearchParams] = useSearchParams();
  const [searchTitleValue1, setSearchTitleValue1] = useState(
    searchParams.get("title1") ?? ""
  );
  const [searchTitleValue2, setSearchTitleValue2] = useState(
    searchParams.get("title2") ?? ""
  );
  const { debounce, flush } = useDebounce(300);
  const handleChangeInput1 = (value: string) => {
    debounce(() => {
      setSearchParams(
        (searchParams) => {
          if (value.length) {
            searchParams.set("title1", value);
          } else {
            searchParams.delete("title1");
          }
          return searchParams;
        },
        { replace: true }
      );
    }),
      setSearchTitleValue1(value);
  };

  (中略)

  return (
    <div>
      <input
        type="text"
        value={searchTitleValue1}
        placeholder="検索ボックス1"
        onChange={(e) => handleChangeInput1(e.target.value)}
        onBlur={() => flush()}
      />
      <input
        type="text"
        value={searchTitleValue2}
        placeholder="検索ボックス2"
        onChange={(e) => handleChangeInput2(e.target.value)}
        onBlur={() => flush()}
      />
      {searchTitleValue1 === "" && searchTitleValue2 === "" ? (
        <p>テキストボックスを入力してください!</p>
      ) : (
        <pre>{JSON.stringify(hogeList)}</pre>
      )}
    </div>
  );
}

またonBlurイベントでflush関数を実行するようにしていますが、これは 2 つのインクリメンタルサーチを高速に実行した場合に1つ目のインクリメンタルサーチの結果がクエリパラメータに反映されないのを防ぐためです。

下記のように debounce の待機時間以内に次のフォームに入力してしまうと、待機中だった関数が打ち消されてしまいクエリパラメータに反映されません。

Image from Gyazo

おわりに

debounce 処理といえば基本的には値の受け渡しをする hooks が多いイメージですが、このようにすれば関数そのものを debounce させることができるのは盲点でした。

似たようなリアルタイム検索機能を実装していらっしゃる方がいれば、参考になれば嬉しいです。最後までご覧いただきありがとうございました!

参考記事

https://zenn.dev/bom_shibuya/articles/bd9c84bfe59f4f

https://tech.techtouch.jp/entry/realtime-search-optimization-debounce-react-app

COUNTERWORKS テックブログ

Discussion