Every search input looks simple — until you ship it. Then you discover the API fires 30 requests per second, keyboard users can't navigate results, and screen readers announce nothing useful.
Building a good autocomplete means solving four problems at once:
- Performance: Avoiding API spam with every keystroke (debouncing)
- Usability: Full keyboard navigation (Arrow keys, Enter, Escape)
- Robustness: Loading states, empty results, outside click dismissal
- Accessibility: Proper ARIA roles so screen readers work correctly
Here's how to build one from scratch with React, TypeScript, and Tailwind CSS — and when you should reach for a library instead.
The Tech Stack
- React 18+ — functional components and hooks (
useState,useEffect,useRef) - TypeScript — type safety and better developer experience
- Tailwind CSS — rapid, utility-first styling
Step 1: Custom useDebounce Hook
The biggest mistake with autocomplete is firing an API request on every onChange event. A fast typist generates dozens of requests per second. Debouncing waits until the user stops typing before triggering the search.
1// hooks/useDebounce.ts
2import { useEffect, useState } from "react";
3
4export function useDebounce<T>(value: T, delay: number = 500): T {
5 const [debouncedValue, setDebouncedValue] = useState<T>(value);
6
7 useEffect(() => {
8 const timer = setTimeout(() => {
9 setDebouncedValue(value);
10 }, delay);
11
12 // Cancel the timer if value changes before delay expires
13 return () => {
14 clearTimeout(timer);
15 };
16 }, [value, delay]);
17
18 return debouncedValue;
19}
20The cleanup function is the key — every new keystroke clears the previous timer, so the callback only fires after delay milliseconds of silence.
Step 2: Component Structure and State
The component needs five pieces of state:
query— what the user typedsuggestions— results from the APIisLoading— loading indicatorisOpen— dropdown visibilityactiveIndex— keyboard-highlighted item (-1 = none)
1// components/Autocomplete.tsx
2import { useState, useEffect, useRef, KeyboardEvent } from "react";
3import { useDebounce } from "../hooks/useDebounce";
4
5const MOCK_DATA = [
6 "React", "Next.js", "Vue", "Svelte", "Angular",
7 "TypeScript", "JavaScript", "Tailwind CSS", "Node.js"
8];
9
10// Simulates a network request with 300ms latency
11const mockApiSearch = async (query: string): Promise<string[]> => {
12 return new Promise((resolve) => {
13 setTimeout(() => {
14 const filtered = MOCK_DATA.filter((item) =>
15 item.toLowerCase().includes(query.toLowerCase())
16 );
17 resolve(filtered);
18 }, 300);
19 });
20};
21
22export function Autocomplete() {
23 const [query, setQuery] = useState("");
24 const [suggestions, setSuggestions] = useState<string[]>([]);
25 const [isLoading, setIsLoading] = useState(false);
26 const [isOpen, setIsOpen] = useState(false);
27 const [activeIndex, setActiveIndex] = useState(-1);
28
29 const dropdownRef = useRef<HTMLDivElement>(null);
30 const debouncedQuery = useDebounce(query, 500);
31
32 // ... effects, handlers, and JSX below
33}
34Step 3: Fetching Data with Race Condition Protection
The fetch effect triggers whenever debouncedQuery changes. One subtle bug to watch for: if the user types "re", pauses, then types "rea" quickly, the response for "re" might arrive after the response for "rea" — showing stale results.
The cancelled flag prevents this by marking stale closures before applying their results.
1// components/Autocomplete.tsx (continued)
2
3 useEffect(() => {
4 if (!debouncedQuery) {
5 setSuggestions([]);
6 setIsOpen(false);
7 return;
8 }
9
10 let cancelled = false;
11
12 const fetchData = async () => {
13 setIsLoading(true);
14 try {
15 const results = await mockApiSearch(debouncedQuery);
16 if (!cancelled) {
17 setSuggestions(results);
18 setIsOpen(true);
19 setActiveIndex(-1);
20 }
21 } catch {
22 if (!cancelled) {
23 setSuggestions([]);
24 }
25 } finally {
26 if (!cancelled) {
27 setIsLoading(false);
28 }
29 }
30 };
31
32 fetchData();
33
34 // If debouncedQuery changes before fetch resolves,
35 // mark this closure as stale
36 return () => {
37 cancelled = true;
38 };
39 }, [debouncedQuery]);
40For real APIs, you could replace the cancelled flag with an AbortController to also cancel the network request itself.
Step 4: Keyboard Navigation
A good autocomplete keeps users on the keyboard. Arrow keys cycle through results, Enter selects, Escape closes.
1// components/Autocomplete.tsx (continued)
2
3 const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
4 if (!isOpen || suggestions.length === 0) return;
5
6 if (e.key === "ArrowDown") {
7 e.preventDefault();
8 setActiveIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0));
9 } else if (e.key === "ArrowUp") {
10 e.preventDefault();
11 setActiveIndex((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1));
12 } else if (e.key === "Enter" && activeIndex >= 0) {
13 handleSelect(suggestions[activeIndex]);
14 } else if (e.key === "Escape") {
15 setIsOpen(false);
16 }
17 };
18
19 const handleSelect = (value: string) => {
20 setQuery(value);
21 setIsOpen(false);
22 setActiveIndex(-1);
23 // In a real app, call an onSelect callback prop here
24 };
25The e.preventDefault() on arrow keys stops the cursor from jumping to the start/end of the input while navigating.
Step 5: Rendering with ARIA Attributes
The JSX combines the input, a conditional dropdown, and proper ARIA roles for screen reader support. The role="combobox" and aria-activedescendant attributes let assistive technology announce the highlighted option.
1// components/Autocomplete.tsx (render)
2
3 return (
4 <div className="relative w-full max-w-md mx-auto mt-10" ref={dropdownRef}>
5 <label htmlFor="search" className="sr-only">Search</label>
6 <div className="relative">
7 <input
8 id="search"
9 type="text"
10 value={query}
11 onChange={(e) => setQuery(e.target.value)}
12 onKeyDown={handleKeyDown}
13 onFocus={() => query && setIsOpen(true)}
14 placeholder="Search technologies..."
15 className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition"
16 autoComplete="off"
17 role="combobox"
18 aria-autocomplete="list"
19 aria-expanded={isOpen}
20 aria-controls="autocomplete-list"
21 aria-activedescendant={activeIndex >= 0 ? `item-${activeIndex}` : undefined}
22 />
23 {isLoading && (
24 <div className="absolute right-3 top-2.5 text-gray-400">
25 <svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
26 <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
27 <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
28 </svg>
29 </div>
30 )}
31 </div>
32
33 {isOpen && suggestions.length > 0 && (
34 <ul
35 id="autocomplete-list"
36 role="listbox"
37 className="absolute z-10 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-auto"
38 >
39 {suggestions.map((item, index) => (
40 <li
41 key={index}
42 id={`item-${index}`}
43 role="option"
44 aria-selected={index === activeIndex}
45 onClick={() => handleSelect(item)}
46 className={`px-4 py-2 cursor-pointer transition ${
47 index === activeIndex
48 ? "bg-blue-100 text-blue-800"
49 : "text-gray-700 hover:bg-gray-50"
50 }`}
51 onMouseEnter={() => setActiveIndex(index)}
52 >
53 {item}
54 </li>
55 ))}
56 </ul>
57 )}
58
59 {isOpen && !isLoading && query && suggestions.length === 0 && (
60 <div className="absolute z-10 w-full mt-1 bg-white border p-4 text-gray-500 rounded-lg shadow-lg">
61 No results found for “{query}”
62 </div>
63 )}
64 </div>
65 );
66Step 6: Handling Outside Clicks
A polished component closes when clicking elsewhere. Attach a global mousedown listener and check if the click target falls outside the component's ref.
1// components/Autocomplete.tsx (add this useEffect)
2
3 useEffect(() => {
4 const handleClickOutside = (event: MouseEvent) => {
5 if (
6 dropdownRef.current &&
7 !dropdownRef.current.contains(event.target as Node)
8 ) {
9 setIsOpen(false);
10 }
11 };
12
13 document.addEventListener("mousedown", handleClickOutside);
14 return () => {
15 document.removeEventListener("mousedown", handleClickOutside);
16 };
17 }, []);
18When to Build vs. Use a Library
This custom implementation works well for simple string-based searches. But if your requirements grow — multi-select, async loading indicators, grouped options, virtualized lists — consider a battle-tested library:
| Library | Best For |
|---|---|
| Downshift | Maximum flexibility, headless approach |
| Radix UI Combobox | Accessible primitives with styling freedom |
| Headless UI Combobox | Tailwind-native, great with Tailwind CSS |
| React Select | Feature-rich, works out of the box |
Build your own when you need a lightweight solution, want to understand the internals, or have constraints that don't fit existing libraries. Use a library when you need complex features like virtualization, multi-select, or async option loading — the edge cases are harder than they look.
Conclusion
This autocomplete handles the four core challenges: debouncing prevents API spam, keyboard navigation keeps power users happy, race condition protection prevents stale results, and ARIA attributes make it accessible.
From here, you can extend it to handle complex objects instead of strings, connect to a real backend API, or integrate with form libraries like React Hook Form. The patterns — debouncing, cleanup flags, keyboard handlers — transfer directly to production code.
