Building a Production-Ready Autocomplete Search Component in React

Learn how to build a high-performance autocomplete search input from scratch using React hooks, TypeScript, debouncing, keyboard navigation, and Tailwind CSS.

react
typescript
tailwind css
frontend
ux
hooks
Kazi Efazul Karim
7 min read
Building a Production-Ready Autocomplete Search Component in React

🚀 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:

  1. Performance: Avoiding API spam with every keystroke (Debouncing).
  2. Usability: Supporting full keyboard navigation (Arrows/Enter).
  3. Robustness: Handling loading states, empty results, and closing on outside clicks.
  4. 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.

typescript
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
23

Step 2: Component Structure & State Management

Let's outline the state variables we need in our component (Autocomplete.tsx).

We need to track:

  1. What the user typed (query).
  2. The results from the API (suggestions).
  3. Is it currently loading? (isLoading).
  4. Is the dropdown open? (isOpen).
  5. Which item is highlighted via keyboard? (activeIndex).
tsx
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
38

Step 3: Fetching Data with Effects

We use useEffect to trigger the data fetch whenever the debouncedQuery changes.

tsx
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
30

Step 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.

tsx
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
30

Step 5: Putting it together (The JSX & Tailwind)

Now we render the input and the conditional dropdown list, applying styles to highlight the activeIndex.

tsx
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
75

Bonus 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:

typescript
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
19

Conclusion

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.

Share this article

Help others discover this content