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
signalyou pass to a request; callingabort()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.
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.
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.
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
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
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
Close on blur
Add
onBlur={() => setPicked(text)}to the input and removeonMouseDown={(e) => e.preventDefault()}from the options. Click an option with the mouse: nothing gets picked. Put thepreventDefaultback and it works again.
Code & diagrams
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
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: reactThe 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
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”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.
Break #2
Rendering highlighted results as HTML
Highlight with dangerouslySetInnerHTML={{ __html: item.replace(query, "<mark>" + query + "</mark>") }}.
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"andenterKeyHint="search"show the right keyboard and Enter label, andautoComplete="off"stops the browser's own suggestions from covering yours.
Remember this
- 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
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 anAbortController(pass itssignaltofetch, callabort()in the effect's cleanup, and ignore theAbortError), or ignore it with anignoreflag set in cleanup. Cancelling also saves the server and the network work. - 3
Caching. Users type, delete and retype the same prefixes. Keep a
Mapfrom 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], withplaceholderData: keepPreviousDatato keep the old list on screen while the new one loads. - 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 withdangerouslySetInnerHTML, 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). Userole="status"orrole="alert"so screen readers hear the change. - 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 getsrole="combobox",aria-expanded,aria-controls(the listbox id) andaria-autocomplete="list"; the popup isrole="listbox"; each item isrole="option"witharia-selectedon the active one. Keys: ArrowDown/ArrowUp move through options (wrapping), Enter picks the active one, Escape closes the list, Tab leaves normally. UseuseId()(Topic 10.4) for the ids. Phase 18 covers the pattern in depth. - 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. CallingpreventDefault()in the option'sonMouseDownkeeps focus in the input, so theclickarrives. 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
Explain debounce and throttle and give a use case for each.
How do you prevent stale search results from overwriting fresh ones?
Walk through the ARIA and keyboard behaviour of an autocomplete.
What states should a search UI handle?
Practice
Write a useDebouncedValue(value, ms) hook and use it to log a query only after typing stops.
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.