FrontendPro

66Build search-as-you-type with debounce and AbortController

mediumAbout 35 minutesCodingFreereactdebounceabortcontrollerrace-conditions

Problem

Build a <Search searchApi={searchApi} delay={300} /> component. searchApi(query, { signal }) returns a promise of an array of strings.

It renders:

  • an <input aria-label="Search">;
  • <p role="status">Searching…</p> while a request is in flight;
  • the results as <ul><li>…</li></ul>, or <p>No results</p> when a finished search found nothing;
  • <p role="alert">Something went wrong</p> when the request fails.
<Search searchApi={(q, { signal }) => fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal }).then((r) => r.json())} />

Requirements

  • Debounce: only search after the user has stopped typing for delay ms. Typing "react" quickly sends one request.
  • Trim the query; an empty query clears the results and sends nothing.
  • When a new search starts, abort the previous in-flight request, and never show results for an older query.
  • Keep the input responsive at all times.

Try it yourself

Write your solution, run it, then submit to check it against the tests.

App.jsxReact + DOM

Step by step

Build the solution one idea at a time.

  1. 1

    Separate what the user types from what you search

    Keep query in state and update it on every keystroke, so the input stays responsive. Derive a debounced value from it with a timer in an effect (or reuse a useDebounce hook): a new keystroke clears the previous timer.

  2. 2

    Search in an effect keyed on the debounced query

    When the debounced, trimmed query changes, run the search in an effect. For an empty query, clear the results and stop.

  3. 3

    One AbortController per search

    Create a controller in the effect, pass { signal } to searchApi, and abort it in the effect cleanup. React runs the cleanup when the debounced query changes again or the component unmounts, so only the latest request survives.

  4. 4

    Track status explicitly

    Use a small status value: "idle", "loading", "success" or "error". It makes the UI easy to read and avoids impossible combinations such as "loading and error".

  5. 5

    Ignore aborts, guard late results

    In catch, return early if the signal was aborted: that is a cancellation, not an error. Before setting results, check !signal.aborted, so a response that slipped in just before the abort cannot overwrite newer results.

Approach

Two effects: one debounces the typed text, the other runs a cancellable search for the debounced text. Each search owns an AbortController that its cleanup aborts, so stale requests are cancelled and stale responses ignored.

Solution

import { useEffect, useState } from "react";

function Search({ searchApi, delay = 300 }) {
  const [query, setQuery] = useState("");
  const [debounced, setDebounced] = useState("");
  const [results, setResults] = useState([]);
  const [status, setStatus] = useState("idle");

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(query.trim()), delay);
    return () => clearTimeout(timer);
  }, [query, delay]);

  useEffect(() => {
    if (!debounced) {
      setResults([]);
      setStatus("idle");
      return;
    }
    const controller = new AbortController();
    setStatus("loading");
    searchApi(debounced, { signal: controller.signal }).then(
      (items) => {
        if (controller.signal.aborted) return;
        setResults(items);
        setStatus("success");
      },
      () => {
        if (controller.signal.aborted) return;
        setStatus("error");
      },
    );
    return () => controller.abort();
  }, [debounced, searchApi]);

  return (
    <div>
      <input aria-label="Search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search fruits" />
      {status === "loading" && <p role="status">Searching…</p>}
      {status === "error" && <p role="alert">Something went wrong</p>}
      {status === "success" && results.length === 0 && <p>No results</p>}
      {status === "success" && results.length > 0 && (
        <ul>
          {results.map((item) => (
            <li key={item}>{item}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

// Preview: a fake API with a random delay.
const FRUITS = ["apple", "apricot", "banana", "blackberry", "blueberry", "cherry", "grape", "mango", "melon", "orange", "peach", "pear"];
function fakeSearch(query, { signal }) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => resolve(FRUITS.filter((f) => f.includes(query.toLowerCase()))), 200 + Math.random() * 400);
    signal.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("Aborted", "AbortError")); });
  });
}

function App() {
  return <Search searchApi={fakeSearch} />;
}

Complexity

Time: one request per pause in typing. Space: O(results).

Edge cases

  • Typing quickly sends a single request for the final text.
  • Clearing the input clears the results without a request.
  • " react " and "react" are the same search.
  • A slow response for an old query is aborted and can never replace newer results.
  • An aborted request is not shown as an error.

Follow-up questions

Answer each one out loud first, then reveal the model answer.