debounce 処理を利用してリアルタイム検索機能を改善してみた
はじめに
実務でインクリメンタルサーチ機能(=リアルタイム検索)のパフォーマンスを改善するために 「debounce 処理」(後述します)を実装する必要がありましたので、工夫した点などについて書いていこうと思います。
興味ある方はぜひ最後までご覧ください。
debounce とは?
まず最初に「debounce」について軽く説明しておきます。
debounce とは 「対象のイベントが発生してから指定した時間が経過するまでは、同じイベントの発生を抑制する仕組み」 です。
もっとシンプルに言うと、対象のイベントにより連続で実行された関数たちの中で 1 番最後の関数だけ実行させる..といったイメージでしょうか?
(下記だと 3 と 6 だけしか実行されない)
言葉だけではイメージをつかめない方は、以下のサンプルコードを実際に動かしてみてください。
これまでのリアルタイム検索処理と課題
さてここからが本題です。
まず前提としてこのリアルタイム検索機能は、検索処理自体はフロントエンドで行わずにバックエンドの検索用 API を経由して行うようになっています。
具体的には以下のように
- ユーザーが入力する
-
onChange
イベントでアドレスバーのクエリパラメータを更新 - クエリパラメータに基づいて検索処理用の API を呼び出す
- 取得した情報を元に検索結果を描画
という流れで行っています。
ただこれをそのまま愚直に実行してしまうと、ユーザー入力のたびに 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
の中身を見てもらっても、実態としてはただ値の受け渡しを行っているだけであることがわかると思います。)
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
を用いることが推奨されています。
今回の debounce 処理に登場するtimer
はまさにそのようなケースに該当し、コンポーネントがアンマウントされるまで同じものを参照できるようにしています。
また、flush
という関数は debounce の遅延を待たずに直ちに処理を実行させるために利用するためのものです。
例えば、ユーザーがテキスト入力を行っていて、その入力を debounce してサーバー側に送信する場合を考えてみましょう。
ユーザーが入力を停止したときに自動的にデータを送信するためには debounce を使用できますが、ユーザーが明示的に「送信」ボタンを押した場合は、debounce の遅延を待たずに直ちにデータを送信したいでしょう。そのような場合に flush 関数を使用して、debounce された関数を直ちに実行します。
実際の実装例
最後に上記で作成したカスタムフックの使用例をサンプルコードとともに載せておきます。
(実際の実装内容と近くなるようにしています。)
処理の流れとしては以下のようなイメージです。
useDebounce
の使い方は非常にシンプルです。
予めtimeout
をもらっておいて、あとは使いたい関数をラップするだけです。
ここでは、setSearchParams
というクエリパラメータを更新する setter 関数をラップしています。
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 の待機時間以内に次のフォームに入力してしまうと、待機中だった関数が打ち消されてしまいクエリパラメータに反映されません。
おわりに
debounce 処理といえば基本的には値の受け渡しをする hooks が多いイメージですが、このようにすれば関数そのものを debounce させることができるのは盲点でした。
似たようなリアルタイム検索機能を実装していらっしゃる方がいれば、参考になれば嬉しいです。最後までご覧いただきありがとうございました!
参考記事
ポップアップストアや催事イベント向けの商業スペースを簡単に予約できる「SHOPCOUNTER」と商業施設向けリーシングDXシステム「SHOPCOUNTER Enterprise」を運営しています。エンジニア採用強化中ですので、興味ある方はお気軽にご連絡ください! counterworks.co.jp/
Discussion