Build an Autocomplete / Typeahead
Learn how to build a production-quality autocomplete input from scratch. This is one of the most popular frontend interview questions — it tests debouncing, async data fetching, keyboard navigation, and accessible UI patterns all in one component.
Table of Contents
What is Autocomplete?
Autocomplete (also called typeahead) is a UI pattern where the application predicts and suggests completions as the user types into an input field. It reduces effort, speeds up input, and helps users discover options they might not know exist.
Search Engines
Google, YouTube, and Amazon all suggest queries as you type — reducing keystrokes and guiding users to popular searches.
Form Inputs
Address fields, tagging systems, and mention pickers (@user) all rely on typeahead to speed up data entry.
Command Palettes
VS Code's Cmd+P, Spotlight, and Slack's slash commands all use autocomplete to surface actions quickly.
Why interviewers love this question
Autocomplete is a "compound" question. It tests debouncing, async state management, keyboard event handling, accessible markup (ARIA), and CSS positioning — all in a single component. It's hard to fake your way through it.
How It Works
The mental model is straightforward: user types → fetch suggestions → display dropdown → user picks one. But the devil is in the details.
User types in the input
Each keystroke updates the input value. But we don't fire an API call on every keystroke — that would be wasteful.
Debounce triggers the search
After the user stops typing for ~300ms, the debounced function fires and sends the query to the API (or filters a local dataset).
Suggestions appear in a dropdown
The API returns matching results. We render them in an absolutely-positioned dropdown below the input, with the matching text highlighted.
User selects a suggestion
The user can click a suggestion or use keyboard arrows + Enter. The input is populated with the selected value and the dropdown closes.
User types: "app" Keystroke: a p p | | | Time: 0ms 100ms 200ms ... 500ms ↓ ↓ ↓ ↓ Debounce: set reset reset FIRES → fetch("/api?q=app") ↓ Results: ["Apple", "Appricot"] ↓ Render dropdown with highlights: [App]le, [App]ricot
Core Implementation
Let's start with the basic structure — an input that fetches suggestions and renders them in a dropdown. We'll add debouncing, keyboard nav, and highlighting in subsequent sections.
"use client"; import { useState, useRef, useEffect } from "react"; interface AutocompleteProps { fetchSuggestions: (query: string) => Promise<string[]>; } export default function Autocomplete({ fetchSuggestions }: AutocompleteProps) { const [query, setQuery] = useState(""); const [suggestions, setSuggestions] = useState<string[]>([]); const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const wrapperRef = useRef<HTMLDivElement>(null); // Close dropdown on outside click useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { setIsOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const value = e.target.value; setQuery(value); if (!value.trim()) { setSuggestions([]); setIsOpen(false); return; } setIsLoading(true); const results = await fetchSuggestions(value); setSuggestions(results); setIsOpen(results.length > 0); setIsLoading(false); }; const handleSelect = (value: string) => { setQuery(value); setIsOpen(false); setSuggestions([]); }; return ( <div ref={wrapperRef} className="relative"> <input type="text" value={query} onChange={handleChange} placeholder="Search..." /> {isLoading && ( <div className="absolute mt-1 w-full border rounded p-2 text-sm text-gray-400"> Loading... </div> )} {isOpen && !isLoading && ( <ul className="absolute mt-1 w-full border rounded shadow max-h-60 overflow-y-auto"> {suggestions.map((item) => ( <li key={item} onClick={() => handleSelect(item)} className="px-3 py-2 cursor-pointer hover:bg-gray-100" > {item} </li> ))} </ul> )} </div> ); }
Interview tip
Start with this basic version. Get it working first, then layer on debouncing, keyboard nav, and highlighting. Interviewers prefer incremental progress over a half-finished complex solution.
Debouncing the Input
Without debouncing, every keystroke fires an API call. Typing "apple" would trigger 5 requests. With debouncing, we wait until the user pauses, then fire a single request.
import { useRef, useCallback } from "react"; function useDebounce<T extends (...args: any[]) => void>( fn: T, delay: number ) { const timerRef = useRef<ReturnType<typeof setTimeout>>(); return useCallback( (...args: Parameters<T>) => { clearTimeout(timerRef.current); timerRef.current = setTimeout(() => fn(...args), delay); }, [fn, delay] ); }
Now wire it into the component. The key insight: update the input value immediately (so the UI feels responsive), but debounce the API call.
const fetchResults = async (value: string) => { if (!value.trim()) { setSuggestions([]); setIsOpen(false); setIsLoading(false); return; } setIsLoading(true); const results = await fetchSuggestions(value); setSuggestions(results); setIsOpen(results.length > 0); setIsLoading(false); }; const debouncedFetch = useDebounce(fetchResults, 300); const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const value = e.target.value; setQuery(value); // Immediate — keeps input responsive setIsLoading(true); // Show spinner right away debouncedFetch(value); // Actual fetch is debounced };
Race condition warning
If the user types "ap", pauses, then types "app", two requests fire. The response for "ap" might arrive after "app", showing stale results. Fix this with an AbortController or by checking if the query still matches when the response arrives.
const abortRef = useRef<AbortController>(); const fetchResults = async (value: string) => { // Cancel the previous in-flight request abortRef.current?.abort(); abortRef.current = new AbortController(); try { const results = await fetchSuggestions(value, abortRef.current.signal); setSuggestions(results); setIsOpen(results.length > 0); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") { return; // Intentionally cancelled — ignore } console.error(err); } finally { setIsLoading(false); } };
Keyboard Navigation
A proper autocomplete must be navigable with the keyboard. This is both a UX requirement and an accessibility requirement.
| Key | Behavior |
|---|---|
| ArrowDown | Move highlight to the next suggestion (wrap to top) |
| ArrowUp | Move highlight to the previous suggestion (wrap to bottom) |
| Enter | Select the currently highlighted suggestion |
| Escape | Close the dropdown without selecting |
const [focusedIndex, setFocusedIndex] = useState(-1); // Reset focused index when suggestions change useEffect(() => { setFocusedIndex(-1); }, [suggestions]); const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { if (!isOpen) return; switch (e.key) { case "ArrowDown": e.preventDefault(); setFocusedIndex((prev) => prev < suggestions.length - 1 ? prev + 1 : 0 ); break; case "ArrowUp": e.preventDefault(); setFocusedIndex((prev) => prev > 0 ? prev - 1 : suggestions.length - 1 ); break; case "Enter": e.preventDefault(); if (focusedIndex >= 0) { handleSelect(suggestions[focusedIndex]); } break; case "Escape": setIsOpen(false); setFocusedIndex(-1); break; } }; // On the input: <input onKeyDown={handleKeyDown} ... /> // On each list item — highlight the focused one: <li className={`px-3 py-2 cursor-pointer ${ index === focusedIndex ? "bg-gray-100" : "hover:bg-gray-50" }`} > {item} </li>
Scroll into view
When the user arrows past the visible area, the highlighted item should scroll into view. Add a ref to the list and call scrollIntoView({ block: "nearest" }) on the focused element whenever focusedIndex changes.
Highlighting Matched Text
A polished autocomplete highlights the portion of each suggestion that matches the user's query. This gives visual feedback about why each result was returned.
function HighlightMatch({ text, query }: { text: string; query: string }) { if (!query.trim()) return <span>{text}</span>; // Escape special regex characters in the query const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const regex = new RegExp(`(${escaped})`, "gi"); const parts = text.split(regex); return ( <span> {parts.map((part, i) => regex.test(part) ? ( <mark key={i} className="bg-yellow-100 text-yellow-900 rounded px-0.5"> {part} </mark> ) : ( <span key={i}>{part}</span> ) )} </span> ); } // Usage in the dropdown: <li> <HighlightMatch text={suggestion} query={query} /> </li>
Why escape the query?
If the user types a character like "(" or ".", it would break the regex. Always escape user input before using it in a RegExp constructor. This is a common interview gotcha.
Edge Cases & Enhancements
The basic autocomplete covers the happy path. Here are the edge cases interviewers will probe:
| Edge Case | Solution |
|---|---|
| Race conditions | Use AbortController to cancel stale requests, or compare the query at response time with the current input value |
| Empty query | Clear suggestions and close dropdown when input is empty. Don't fire API calls for blank strings |
| No results | Show a "No results found" message instead of an empty dropdown |
| API errors | Wrap fetch in try/catch. Show an error state or silently fail — don't crash the component |
| Long lists | Cap results (e.g., top 10) and use max-height + overflow-y-auto on the dropdown |
| Caching | Cache previous results in a Map. If the user types "app", deletes to "ap", then retypes "app", serve from cache |
| Minimum query length | Don't search until the user has typed at least 2-3 characters to avoid overly broad results |
const cacheRef = useRef<Map<string, string[]>>(new Map()); const fetchResults = async (value: string) => { const key = value.toLowerCase().trim(); // Return cached results if available if (cacheRef.current.has(key)) { const cached = cacheRef.current.get(key)!; setSuggestions(cached); setIsOpen(cached.length > 0); setIsLoading(false); return; } const results = await fetchSuggestions(value); cacheRef.current.set(key, results); // Cache for future use setSuggestions(results); setIsOpen(results.length > 0); setIsLoading(false); };
ARIA attributes for accessibility
Use role="combobox" on the input, role="listbox" on the dropdown, and role="option" on each item. Set aria-activedescendant to the ID of the focused option. This makes the component usable with screen readers.
Common Interview Follow-up Questions
After building the autocomplete, expect these follow-ups:
Q:How do you handle race conditions with async suggestions?
A: Use an AbortController to cancel the previous request when a new one fires. Alternatively, store a request ID and only apply results if the ID matches the latest request. This prevents stale data from overwriting fresh results.
Q:How would you add caching to avoid redundant API calls?
A: Use a Map (or useRef with a Map) keyed by the normalized query string. Before fetching, check the cache. This is especially useful when users delete characters and retype — the results are already available instantly.
Q:What ARIA attributes does an autocomplete need?
A: The input needs role='combobox', aria-expanded, aria-autocomplete='list', and aria-activedescendant pointing to the focused option's ID. The dropdown needs role='listbox'. Each option needs role='option' and a unique ID. This follows the WAI-ARIA combobox pattern.
Q:How would you handle a very large dataset (100k+ items)?
A: Never filter on the client. Use server-side search with LIMIT/pagination. On the frontend, virtualize the dropdown list (only render visible items). Also consider a minimum query length (2-3 chars) to avoid overly broad searches.
Q:What's the difference between autocomplete and a combobox?
A: Autocomplete suggests completions for free-text input — the user can type anything. A combobox restricts selection to predefined options (like a searchable select). In practice, the terms are often used interchangeably, but the distinction matters for form validation.
Q:How would you debounce vs throttle the input?
A: Debounce is the right choice here — you want to wait until the user stops typing. Throttle would fire at regular intervals while typing, which means unnecessary intermediate API calls. Debounce gives you one call after the burst ends.
Q:How do you handle the dropdown positioning near the bottom of the viewport?
A: Check if there's enough space below the input. If not, flip the dropdown above. Libraries like Floating UI handle this automatically. In an interview, mentioning this shows you think about real-world UX.
Ready to implement it yourself?
We've set up a playground with a mock API and a search input. Build the full autocomplete with debouncing, keyboard navigation, and text highlighting.
Built for developers, by developers. Happy coding! 🚀