React useDebounce Hook

by varundubey March 11, 2026 Public

A reusable React hook for debouncing values, with TypeScript types and usage example.

49 views Raw Download Revisions (v1)
useDebounce.ts typescript Raw
import { useState, useEffect } from "react";

/**
 * Debounce a value by a given delay.
 *
 * Returns the debounced value which only updates
 * after the specified delay has elapsed without changes.
 */
export function useDebounce<T>(value: T, delay: number = 300): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

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

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

  return debouncedValue;
}
SearchInput.tsx tsx Raw
import { useState, useEffect } from "react";
import { useDebounce } from "./useDebounce";

interface SearchResult {
  id: number;
  title: string;
  excerpt: string;
}

interface SearchInputProps {
  endpoint: string;
  placeholder?: string;
  onResults?: (results: SearchResult[]) => void;
}

export function SearchInput({
  endpoint,
  placeholder = "Search...",
  onResults,
}: SearchInputProps) {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<SearchResult[]>([]);
  const [isLoading, setIsLoading] = useState(false);

  const debouncedQuery = useDebounce(query, 300);

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

    const controller = new AbortController();

    async function search() {
      setIsLoading(true);
      try {
        const url = `${endpoint}?q=${encodeURIComponent(debouncedQuery)}`;
        const res = await fetch(url, { signal: controller.signal });
        const data: SearchResult[] = await res.json();
        setResults(data);
        onResults?.(data);
      } catch (err) {
        if (err instanceof DOMException && err.name === "AbortError") return;
        console.error("Search failed:", err);
      } finally {
        setIsLoading(false);
      }
    }

    search();
    return () => controller.abort();
  }, [debouncedQuery, endpoint, onResults]);

  return (
    <div className="search-input">
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder={placeholder}
        aria-label="Search"
      />
      {isLoading && <span className="search-spinner" aria-hidden="true" />}
      {results.length > 0 && (
        <ul className="search-results" role="listbox">
          {results.map((item) => (
            <li key={item.id} role="option">
              <strong>{item.title}</strong>
              <p>{item.excerpt}</p>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
Skip to toolbar