🚀 Building a Production-Ready Autocomplete Search Component in React
An autocomplete (or typeahead) search input is a staple of modern web UI. It improves user experience by guiding users toward existing results and reducing typing effort.
While it looks simple on the surface—an input and a list—building a good one requires handling several complex challenges:
- Performance: Avoiding API spam with every keystroke (Debouncing).
- Usability: Supporting full keyboard navigation (Arrows/Enter).
- Robustness: Handling loading states, empty results, and closing on outside clicks.
- Accessibility (A11y): Ensuring screen readers can interpret the component.
In this guide, we will build a robust, reusable autocomplete component from scratch using React (Hooks), TypeScript, and Tailwind CSS.
The Tech Stack
- React 18+: Using functional components and hooks (
useState,useEffect,useRef). - TypeScript: For type safety and better developer experience.
- Tailwind CSS: For rapid, utility-first styling.
Step 1: The Secret Sauce (Custom useDebounce Hook)
The biggest mistake beginners make with autocomplete is firing an API request on every single onChange event. If a user types fast, this can cripple your server.
We need debouncing: waiting until the user has stopped typing for a few milliseconds before triggering the search.
Let's create a reusable custom hook for this.
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 // Set a timer to update the debounced value after the specified delay
9 const timer = setTimeout(() => {
10 setDebouncedValue(value);
11 }, delay);
12
13 // Clean up the timer if the value changes before the delay expires
14 // This is the magic part that cancels previous typing actions
15 return () => {
16 clearTimeout(timer);
17 };
18 }, [value, delay]);
19
20 return debouncedValue;
21}
22
23Step 2: Component Structure & State Management
Let's outline the state variables we need in our component (Autocomplete.tsx).
We need to track:
- What the user typed (
query). - The results from the API (
suggestions). - Is it currently loading? (
isLoading). - Is the dropdown open? (
isOpen). - Which item is highlighted via keyboard? (
activeIndex).
1// components/Autocomplete.tsx
2import { useState, useEffect, useRef, KeyboardEvent } from "react";
3import { useDebounce } from "../hooks/useDebounce";
4
5// Mock data for demonstration
6const MOCK_DATA = [
7 "React", "Next.js", "Vue", "Svelte", "Angular", "TypeScript", "JavaScript", "Tailwind CSS", "Node.js"
8];
9
10// Mock API function simulating network delay
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); // Simulate 300ms network latency
19 });
20};
21
22export default 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); // -1 means nothing selected
28
29 const dropdownRef = useRef<HTMLDivElement>(null);
30
31 // Use our custom hook! Wait 500ms after typing stops.
32 const debouncedQuery = useDebounce(query, 500);
33
34 // ... effect logic will go here
35 // ... render JSX will go here
36}
37
38Step 3: Fetching Data with Effects
We use useEffect to trigger the data fetch whenever the debouncedQuery changes.
1// components/Autocomplete.tsx (continued)
2
3 useEffect(() => {
4 // Don't search for empty strings
5 if (!debouncedQuery) {
6 setSuggestions([]);
7 setIsOpen(false);
8 return;
9 }
10
11 const fetchData = async () => {
12 setIsLoading(true);
13 try {
14 const results = await mockApiSearch(debouncedQuery);
15 setSuggestions(results);
16 setIsOpen(true);
17 setActiveIndex(-1); // Reset keyboard selection on new search
18 } catch (error) {
19 console.error("Error fetching data:", error);
20 setSuggestions([]);
21 } finally {
22 setIsLoading(false);
23 }
24 };
25
26 fetchData();
27 }, [debouncedQuery]);
28
29
30Step 4: Handling Keyboard Navigation (UX Gold)
A good autocomplete allows users to keep their hands on the keyboard. We need to handle ArrowDown, ArrowUp, and Enter.
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(); // Prevent cursor moving in input
8 // Cycle down, loop back to top if at bottom
9 setActiveIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0));
10 } else if (e.key === "ArrowUp") {
11 e.preventDefault();
12 // Cycle up, loop to bottom if at top
13 setActiveIndex((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1));
14 } else if (e.key === "Enter" && activeIndex >= 0) {
15 // Select the active item
16 handleSelect(suggestions[activeIndex]);
17 } else if (e.key === "Escape") {
18 setIsOpen(false);
19 }
20 };
21
22 const handleSelect = (value: string) => {
23 setQuery(value);
24 setIsOpen(false);
25 setActiveIndex(-1);
26 // Optionally trigger an onSelect callback prop here
27 console.log("Selected:", value);
28 };
29
30Step 5: Putting it together (The JSX & Tailwind)
Now we render the input and the conditional dropdown list, applying styles to highlight the activeIndex.
1// components/Autocomplete.tsx (Final Render part)
2
3 return (
4 // Use ref for "click outside" detection later
5 <div className="relative w-full max-w-md mx-auto mt-10" ref={dropdownRef}>
6 <label htmlFor="search" className="sr-only">Search</label>
7 <div className="relative">
8 <input
9 id="search"
10 type="text"
11 value={query}
12 onChange={(e) => setQuery(e.target.value)}
13 onKeyDown={handleKeyDown}
14 onFocus={() => query && setIsOpen(true)}
15 placeholder="Search technologies..."
16 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"
17 autoComplete="off"
18 // A11y attributes
19 role="combobox"
20 aria-autocomplete="list"
21 aria-expanded={isOpen}
22 aria-controls="autocomplete-list"
23 aria-activedescendant={activeIndex >= 0 ? `item-${activeIndex}` : undefined}
24 />
25 {/* Loading spinner icon */}
26 {isLoading && (
27 <div className="absolute right-3 top-2.5 text-gray-400">
28 {/* Simple SVG Spinner */}
29 <svg className="animate-spin h-5 w-5" xmlns="[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)" fill="none" viewBox="0 0 24 24">
30 <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
31 <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>
32 </svg>
33 </div>
34 )}
35 </div>
36
37 {/* Results Dropdown */}
38 {isOpen && suggestions.length > 0 && (
39 <ul
40 id="autocomplete-list"
41 role="listbox"
42 className="absolute z-10 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-auto"
43 >
44 {suggestions.map((item, index) => (
45 <li
46 key={index}
47 id={`item-${index}`}
48 role="option"
49 aria-selected={index === activeIndex}
50 onClick={() => handleSelect(item)}
51 // Conditional styling based on activeIndex
52 className={`px-4 py-2 cursor-pointer transition ${
53 index === activeIndex
54 ? "bg-blue-100 text-blue-800" // Highlighted state
55 : "text-gray-700 hover:bg-gray-50" // Normal state
56 }`}
57 // Handle mouse hover interaction mixed with keyboard interaction
58 onMouseEnter={() => setActiveIndex(index)}
59 >
60 {item}
61 </li>
62 ))}
63 </ul>
64 )}
65
66 {/* No results state */}
67 {isOpen && !isLoading && query && suggestions.length === 0 && (
68 <div className="absolute z-10 w-full mt-1 bg-white border p-4 text-gray-500 rounded-lg shadow-lg">
69 No results found for "{query}"
70 </div>
71 )}
72 </div>
73 );
74
75Bonus Step: Handling Outside Clicks
A polished component closes when you click somewhere else on the page. We use the dropdownRef and a global event listener for this.
Add this to your component's useEffect block:
1 // Close dropdown when clicking outside
2 useEffect(() => {
3 const handleClickOutside = (event: MouseEvent) => {
4 if (
5 dropdownRef.current &&
6 !dropdownRef.current.contains(event.target as Node)
7 ) {
8 setIsOpen(false);
9 }
10 };
11
12 document.addEventListener("mousedown", handleClickOutside);
13 return () => {
14 // Important cleanup!
15 document.removeEventListener("mousedown", handleClickOutside);
16 };
17 }, []);
18
19Conclusion
We have successfully built a production-ready Autocomplete component.
✅ Performant: It uses useDebounce to limit network requests.
✅ Usable: It supports full keyboard arrow navigation and selection.
✅ Polished: It handles loading states and outside clicks.
✅ Accessible: It uses proper ARIA roles for screen readers.
This approach provides a solid foundation that you can easily extend to handle complex objects instead of strings, connect to a real backend API, or integrate with form libraries like React Hook Form.
Related on this site
- Designing a feed UI with the same production mindset? Read Cracking the Frontend System Design Interview: Designing an Instagram Feed.
- Putting components like this on a page that converts? See Beyond the Fold: Anatomy of a High-Conversion Landing Page.
- See production React work in the Fashion E-commerce Frontend project.
