レビュー済み·難易度: 中級·更新: 2026-04-18

useDebounce カスタムフックで検索入力をデバウンスする

useDebounce カスタムフックを自作し、検索入力のデバウンス処理を実装する例。lodash.debounce を使わず useEffect + setTimeout で実現する。

nextjssearch-filterperformance

対応バージョン

nextjs 15react 19

前提環境

React の useState / useEffect の基本を理解していること

概要

useDebounce カスタムフックを useEffect + setTimeout で自作し、検索入力のデバウンス処理を実装する。lodash.debounce を使わず外部依存なしで実現することで、仕組みを理解しやすくする。入力が止まった後に API リクエストを送るパターンを示す。

インストール

# 追加インストールは不要

実装

useDebounce フック

// hooks/useDebounce.ts
import { useEffect, useState } from "react";

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

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

    // 次の effect 実行前(または アンマウント時)にタイマーをキャンセル
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

検索コンポーネント

// components/SearchBox.tsx
"use client";

import { useEffect, useState } from "react";
import { useDebounce } from "@/hooks/useDebounce";

type Result = { id: number; title: string };

export function SearchBox() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<Result[]>([]);
  const [isSearching, setIsSearching] = useState(false);

  const debouncedQuery = useDebounce(query, 400);

  useEffect(() => {
    if (!debouncedQuery.trim()) {
      setResults([]);
      return;
    }

    setIsSearching(true);

    fetch(
      `https://jsonplaceholder.typicode.com/posts?title_like=${encodeURIComponent(debouncedQuery)}&_limit=5`
    )
      .then((res) => res.json())
      .then((data: Result[]) => {
        setResults(data);
      })
      .finally(() => {
        setIsSearching(false);
      });
  }, [debouncedQuery]);

  return (
    <div className="w-full max-w-md">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="キーワードを入力..."
        className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
      />

      {isSearching && (
        <p className="mt-2 text-sm text-gray-400">検索中...</p>
      )}

      {!isSearching && results.length > 0 && (
        <ul className="mt-2 space-y-1">
          {results.map((item) => (
            <li key={item.id} className="rounded border p-2 text-sm text-gray-700">
              {item.title}
            </li>
          ))}
        </ul>
      )}

      {!isSearching && debouncedQuery && results.length === 0 && (
        <p className="mt-2 text-sm text-gray-500">該当なし</p>
      )}
    </div>
  );
}
// app/page.tsx
import { SearchBox } from "@/components/SearchBox";

export default function Page() {
  return (
    <main className="mx-auto max-w-xl p-8">
      <h1 className="mb-6 text-2xl font-bold">検索サンプル</h1>
      <SearchBox />
    </main>
  );
}

ポイント

  • useEffect 内で setTimeout を設定し、クリーンアップ関数で clearTimeout することでデバウンスを実現する
  • 入力が変わるたびに前のタイマーがキャンセルされるため、最後の入力から delay ms 後にだけ値が更新される
  • query(入力値)と debouncedQuery(遅延後の値)を分けることで、入力 UI はリアルタイムに、API リクエストはデバウンス後に送れる
  • delay を引数にすることで呼び出し側で遅延時間を調整できる(一般的には 300〜500ms)
  • useEffect の依存配列に debouncedQuery を含めることで、値が変化したときだけ検索が走る

注意点

lodash.debounce を使わず自作することで依存を減らし、仕組みを理解しやすくする。遅延時間はカスタムフックの引数で調整できる。

関連サンプル