Skip to content
Emmanuel Yegon

Tutorial

Debounce a search box in React, the right way

Fire one request when the user stops typing, not one per keystroke. A small hook, plus the cancellation detail most examples skip.

Emmanuel YegonUpdated 10 Oct 20263 min read

Type "react hooks" into a naive search box and it sends eleven requests, one per character. Ten of them are wasted, and if they come back out of order you can end up showing results for the wrong text.

Debouncing fixes the first problem. Cancelling stale requests fixes the second. You need both.

A reusable hook

A debounced value lags behind the real one until it has been still for a given delay.

use-debounced-value.ts
import { useEffect, useState } from "react";
 
export function useDebouncedValue<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value);
 
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
 
  return debounced;
}

The cleanup function is what makes it work. Each keystroke re-runs the effect, which clears the previous timer before starting a new one. Only a pause longer than delay lets the timer finish.

Keep the input responsive by binding it to the raw value, and fetch using the debounced one.

search-box.tsx
"use client";
 
import { useEffect, useState } from "react";
import { useDebouncedValue } from "./use-debounced-value";
 
export function SearchBox() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<string[]>([]);
  const debouncedQuery = useDebouncedValue(query, 300);
 
  useEffect(() => {
    if (!debouncedQuery.trim()) {
      setResults([]);
      return;
    }
 
    const controller = new AbortController();
 
    fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`, {
      signal: controller.signal,
    })
      .then((res) => res.json())
      .then((data: string[]) => setResults(data))
      .catch((error) => {
        if (error.name !== "AbortError") console.error(error);
      });
 
    return () => controller.abort();
  }, [debouncedQuery]);
 
  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search" />
      <ul>
        {results.map((result) => (
          <li key={result}>{result}</li>
        ))}
      </ul>
    </div>
  );
}

Why the AbortController matters

Debouncing reduces how many requests you send, but it cannot guarantee the order they finish in. If a slow response for "rea" lands after the fast one for "react", the user sees the wrong list.

The effect's cleanup runs whenever debouncedQuery changes, so it aborts the previous request before the next one starts. A stale response can never overwrite a fresh one, and aborted requests are ignored by the AbortError check.

Common mistakes

  • Debouncing the input itself. The text field should update on every keystroke. Debounce what you do with the value.
  • Creating the timer outside an effect. Without the cleanup, timers pile up and fire after the component unmounts.
  • Forgetting the empty query. Check for it, or you will request results for an empty string.

That is the whole pattern: a small hook for timing, and an abort for correctness.

Comments