Command Palette

Search for a command to run...

Hectal
PHASE 24Advanced ~28 min· topic 5 of 10

Topic 24.5

Search, Debounce and Autocomplete

In one line

A search box that suggests as you type waits for a pause before asking the server (debounce), cancels or ignores answers to old queries, caches results it has seen, highlights the matching text, and follows the ARIA combobox pattern so arrow keys, Enter, Escape and screen readers all work, with clear loading, empty and error states.

Think of it like this

A shop assistant listening to a customer. A good assistant doesn't run to the storeroom after every single word ("I want… a… red…"). They wait until the customer pauses, then fetch what was asked for. If the customer changes their mind on the way ("actually, blue"), the assistant forgets the red one. And if someone asks for something they fetched five minutes ago, they remember where it was. That is debounce, cancelling stale requests and caching.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Debounce
Waiting until events stop for a set time, then running once with the latest value.
Throttle
Running at most once per time interval while events keep firing.
Stale response
An answer to an older request that arrives after a newer one and would show the wrong results.
AbortController
A browser object whose signal you pass to a request; calling abort() cancels the request.
LRU cache
A size-limited cache that throws out the Least Recently Used entry when it's full.
Combobox
An input with a popup list of suggestions that the user can pick from with the keyboard or mouse.
Listbox / option
The ARIA roles for the popup list and each choice inside it.
aria-activedescendant
An attribute on the focused input that names the option currently highlighted, so focus never leaves the input.

Step by step

01Debounce the text

Keep the input's text in state so typing is instant, and derive a debounced copy that changes only after 150–300 ms of quiet. Fetch from the debounced copy.

Debounce the textdiagram
Rendering diagram…

02Cancel the old request in the effect's cleanup

Each query gets its own AbortController. When the query changes, React runs the cleanup, which aborts the request still in flight, so its answer can never land.

search.jswhole filejavascript
useEffect(() => {
  const ctrl = new AbortController();
  fetch("/api/search?q=" + encodeURIComponent(query), { signal: ctrl.signal })
    .then((r) => r.json())
    .then(setResults)
    .catch((err) => { if (err.name !== "AbortError") setError(err); });
  return () => ctrl.abort();
}, [query]);

03Check the cache first

Before sending, look the query up in a Map. Store every successful answer. Bound the size in real apps.

04Wire up the combobox roles and keys

The input owns focus; the active option is tracked in state and announced through aria-activedescendant.

Wire up the combobox roles and keysdiagram
Rendering diagram…

05Render every state

Idle, loading, results with highlighted matches, no results, error. Each has its own message, and status changes are announced.

Try it yourself

  1. 1

    Remove the abort

    In the autocomplete, delete return () => ctrl.abort();. Predict the first log line: the slow "b" answer lands last and replaces the "bl" options with every fruit containing "b".

  2. 2

    Change the debounce delay

    In the first example set the debounce to 20 ms. Predict how many debounced calls you get with keystrokes 40 ms apart (five: every gap is longer than the delay). Real typing averages 100–200 ms between keys, which is why 150–300 ms delays are common.

  3. 3

    Close on blur

    Add onBlur={() => setPicked(text)} to the input and remove onMouseDown={(e) => e.preventDefault()} from the options. Click an option with the mouse: nothing gets picked. Put the preventDefault back and it works again.

Code & diagrams

Debounce vs throttle, side by side New tab

Five keystrokes 40ms apart. Debounce waits for 200ms of quiet and runs once with the final text. Throttle (one call per second) runs at once for the first keystroke and once more at the end of its window with the latest value. Type in the box yourself and compare.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

keystroke: r
throttled call: r
keystroke: re
keystroke: rea
keystroke: reac
keystroke: react
debounced call: react
throttled call: react
A complete autocomplete: debounce, abort, cache, highlight, keyboard and ARIA New tab

The fake API answers shorter queries more slowly, so "b" would arrive after "bl": aborting it in the cleanup keeps the right results. Picking with ArrowDown, ArrowDown, Enter selects the second option. Typing "bl" again comes from the cache with no new request. Try it with the keyboard and a screen reader.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Options for 'bl': Blackberry, Blueberry (requests: 2)
Picked with the keyboard: Blueberry
'bl' again from the cache (requests: 2)
No fruit matches “kiwi”
The same search with TanStack Query (not live)jsx
import { useQuery, keepPreviousData } from "@tanstack/react-query";

function useSearch(text) {
  const query = useDebouncedValue(text.trim(), 200);
  return useQuery({
    queryKey: ["search", query],             // one cache entry per query
    queryFn: ({ signal }) =>                 // TanStack aborts the request when it's no longer needed
      fetch("/api/search?q=" + encodeURIComponent(query), { signal }).then((r) => {
        if (!r.ok) throw new Error("Search failed");
        return r.json();
      }),
    enabled: query.length > 0,
    staleTime: 60_000,
    placeholderData: keepPreviousData,       // keep the old list visible while the new one loads
  });
}
// For the combobox itself, Downshift (useCombobox), React Aria (useComboBox) or
// Headless UI (Combobox) implement the keyboard and ARIA rules and are well tested.

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Building the debounced function during render

Debounce with a plain helper inside the component: const search = debounce((q) => fetchResults(q), 300); and call it from onChange.

terminal
$ Network panel
── what you'll see ──
One request per keystroke, each delayed by 300ms: nothing was debounced.

Break #2

Rendering highlighted results as HTML

Highlight with dangerouslySetInnerHTML={{ __html: item.replace(query, "<mark>" + query + "</mark>") }}.

terminal
$ Security review
── what you'll see ──
Any markup inside a search result (from user-generated content) is inserted into the page as real HTML.

Myth vs fact

Myth

Debouncing fixes race conditions.

Fact

It reduces how many requests are sent, but two can still overlap when the user pauses twice. You still need to cancel or ignore stale responses.

Myth

Debounce and throttle are the same thing with different names.

Fact

Debounce runs after the activity stops; throttle runs regularly during it. Search wants debounce; scroll and resize handlers usually want throttle (or requestAnimationFrame).

Myth

An input with a dropdown under it is accessible.

Fact

Without the combobox roles, aria-expanded, aria-activedescendant and keyboard handling, screen reader and keyboard users can't tell suggestions exist or pick one.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    useDeferredValue(text) (Topic 16.7) is an alternative to debouncing for client-side filtering: React renders the filtered list at low priority and interrupts it when the user types again. It doesn't reduce network requests, so server search still wants debounce plus cancellation.

  • ▸

    Many teams also send the query in the response and compare it with the current one before applying it; this protects against stale answers even when a library or a cache layer can't abort.

  • ▸

    Rank on the server for large data (prefix indexes, typo tolerance, popularity), filter on the client for small fixed lists. Debounced server search plus a cache of prefixes gives instant feel; Phase 25 designs a full typeahead.

  • ▸

    On mobile, inputMode="search" and enterKeyHint="search" show the right keyboard and Enter label, and autoComplete="off" stops the browser's own suggestions from covering yours.

Remember this

  1. 1

    Debounce vs throttle. Both limit how often a function runs when an event fires rapidly. Debounce waits until the events have stopped for a set time (say 200 ms), then runs once with the latest value: perfect for search-as-you-type, where only the final text matters. Throttle runs at most once per interval while events keep coming (usually at the start, plus once at the end so the last value isn't lost): right for scroll positions, resize handling or live previews that should update steadily during the action. In React, debounce a value (useDebouncedValue(text, 200) with a timer in an effect) or a callback (a timer kept in a ref); always clear the timer on cleanup so nothing fires after unmount.

  2. 2

    Stale responses: the race condition. Requests don't come back in the order they were sent. If the request for "b" is slow and the one for "bl" is fast, the "b" results can arrive last and overwrite the correct ones (Topic 14.7). Two fixes: cancel the old request with an AbortController (pass its signal to fetch, call abort() in the effect's cleanup, and ignore the AbortError), or ignore it with an ignore flag set in cleanup. Cancelling also saves the server and the network work.

  3. 3

    Caching. Users type, delete and retype the same prefixes. Keep a Map from query to results (bounded in size: an LRU cache drops the least recently used entries), check it before sending a request, and show cached results instantly. TanStack Query does this for you, keyed by ["search", query], with placeholderData: keepPreviousData to keep the old list on screen while the new one loads.

  4. 4

    Showing results well. Highlight the part of each suggestion that matches the query (split the string around the match and wrap it in <mark>; never build HTML strings with dangerouslySetInnerHTML, which would let a malicious result inject markup). Distinguish the states: nothing typed (idle), loading ("Searching…", ideally only if it takes more than a moment), results, no results ("No fruit matches ‘kiwi’", maybe with a suggestion), and error (a message and a way to retry). Use role="status" or role="alert" so screen readers hear the change.

  5. 5

    The combobox pattern (ARIA). An autocomplete is a combobox: a text input that controls a popup listbox of options. Focus stays in the input the whole time; the highlighted option is communicated with aria-activedescendant (the id of the active option) instead of moving focus. The input gets role="combobox", aria-expanded, aria-controls (the listbox id) and aria-autocomplete="list"; the popup is role="listbox"; each item is role="option" with aria-selected on the active one. Keys: ArrowDown/ArrowUp move through options (wrapping), Enter picks the active one, Escape closes the list, Tab leaves normally. Use useId() (Topic 10.4) for the ids. Phase 18 covers the pattern in depth.

  6. 6

    Mouse details that bite. Clicking an option makes the input lose focus first (a blur); if you close the list on blur, the click lands on nothing. Calling preventDefault() in the option's onMouseDown keeps focus in the input, so the click arrives. Also scroll the active option into view as the user arrows through a long list (scrollIntoView({ block: "nearest" })), and decide what happens on Enter with no active option (submit a full search, as most sites do).

Explain it without notes

01

Explain debounce and throttle and give a use case for each.

02

How do you prevent stale search results from overwriting fresh ones?

03

Walk through the ARIA and keyboard behaviour of an autocomplete.

04

What states should a search UI handle?

Practice

01

Write a useDebouncedValue(value, ms) hook and use it to log a query only after typing stops.

02

Add a size limit to the autocomplete cache so it keeps only the 50 most recently used queries.

Trade-offs

  • ↔

    A longer debounce sends fewer requests but feels slower; a shorter one feels snappier but loads the server. 150–300 ms is the usual compromise; add caching to make repeated prefixes instant.

  • ↔

    Hand-built comboboxes are fine for learning and simple cases, but tested libraries (Downshift, React Aria, Headless UI) handle edge cases across screen readers and devices that are easy to miss.

Done when you can

  • I can explain and implement debounce and throttle in React.

  • I can cancel stale requests with AbortController in an effect cleanup.

  • I can cache search results and keep the cache bounded.

  • I can highlight matches safely without dangerouslySetInnerHTML.

  • I can build a combobox with the right roles and ArrowUp/ArrowDown/Enter/Escape keys.

  • I can show idle, loading, empty and error states.