React Curriculum

Machine Coding: Autocomplete Typeahead Search

Hard100% Free~40 mins#react#machine-coding#autocomplete#typeahead#combobox#caching
Interview Answer (30-Second Summary)

A production Autocomplete Typeahead features debounced asynchronous fetching, in-memory caching to eliminate duplicate API requests, full keyboard navigation (ArrowDown, ArrowUp, Enter, Escape), WAI-ARIA combobox accessibility, and click-outside dismissal via ref.

Key Learning Objectives
✓

Implement an Autocomplete Typeahead with debounced async queries and request caching.

✓

Implement keyboard navigation (ArrowUp, ArrowDown, Enter, Escape) with active item highlighting.

✓

Apply WAI-ARIA Combobox 1.2 specifications (role="combobox", aria-autocomplete="list", aria-expanded).

✓

Handle race conditions and out-of-order network responses using AbortController or query sequence IDs.

The Interview Problem & Context

Implement a production `<Autocomplete onSearch={async (query) => ...} onSelect={(item) => ...} />` component with debouncing, keyboard navigation, caching, and click-outside dismissal.

1import React, { useState, useEffect, useRef, KeyboardEvent } from 'react';
2
3interface AutocompleteProps<T> {
4 onSearch: (query: string) => Promise<T[]>;
5 onSelect: (item: T) => void;
6 getLabel: (item: T) => string;
7 placeholder?: string;
8}
9
10export function Autocomplete<T>({
11 onSearch,
12 onSelect,
13 getLabel,
14 placeholder = 'Search...',
15}: AutocompleteProps<T>) {
16 const [query, setQuery] = useState('');
17 const [results, setResults] = useState<T[]>([]);
18 const [isOpen, setIsOpen] = useState(false);
19 const [isLoading, setIsLoading] = useState(false);
20 const [highlightedIndex, setHighlightedIndex] = useState(-1);
21
22 const containerRef = useRef<HTMLDivElement>(null);
23 const cacheRef = useRef<Map<string, T[]>>(new Map());
24
25 // 1. Debounced async search with in-memory caching
26 useEffect(() => {
27 const trimmed = query.trim();
28 if (!trimmed) {
29 setResults([]);
30 setIsOpen(false);
31 return;
32 }
33
34 if (cacheRef.current.has(trimmed)) {
35 setResults(cacheRef.current.get(trimmed)!);
36 setIsOpen(true);
37 return;
38 }
39
40 const timer = setTimeout(async () => {
41 setIsLoading(true);
42 try {
43 const data = await onSearch(trimmed);
44 cacheRef.current.set(trimmed, data);
45 setResults(data);
46 setIsOpen(true);
47 setHighlightedIndex(-1);
48 } catch (err) {
49 console.error('Search error:', err);
50 } finally {
51 setIsLoading(false);
52 }
53 }, 300);
54
55 return () => clearTimeout(timer);
56 }, [query, onSearch]);
57
58 // 2. Dismiss on click outside
59 useEffect(() => {
60 const handleClickOutside = (e: MouseEvent) => {
61 if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
62 setIsOpen(false);
63 }
64 };
65 document.addEventListener('mousedown', handleClickOutside);
66 return () => document.removeEventListener('mousedown', handleClickOutside);
67 }, []);
68
69 // 3. Keyboard navigation
70 const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
71 if (!isOpen || results.length === 0) return;
72
73 switch (e.key) {
74 case 'ArrowDown':
75 e.preventDefault();
76 setHighlightedIndex((prev) => (prev + 1) % results.length);
77 break;
78 case 'ArrowUp':
79 e.preventDefault();
80 setHighlightedIndex((prev) => (prev - 1 + results.length) % results.length);
81 break;
82 case 'Enter':
83 e.preventDefault();
84 if (highlightedIndex >= 0 && highlightedIndex < results.length) {
85 handleItemClick(results[highlightedIndex]);
86 }
87 break;
88 case 'Escape':
89 e.preventDefault();
90 setIsOpen(false);
91 break;
92 }
93 };
94
95 const handleItemClick = (item: T) => {
96 setQuery(getLabel(item));
97 setIsOpen(false);
98 onSelect(item);
99 };
100
101 return (
102 <div ref={containerRef} className="autocomplete-container" style={{ position: 'relative', width: '300px' }}>
103 <input
104 type="text"
105 role="combobox"
106 aria-autocomplete="list"
107 aria-expanded={isOpen}
108 value={query}
109 placeholder={placeholder}
110 onChange={(e) => setQuery(e.target.value)}
111 onFocus={() => results.length > 0 && setIsOpen(true)}
112 onKeyDown={handleKeyDown}
113 className="autocomplete-input"
114 style={{ width: '100%', padding: '8px 12px', boxSizing: 'border-box' }}
115 />
116
117 {isOpen && (
118 <ul
119 role="listbox"
120 className="autocomplete-dropdown"
121 style={{
122 position: 'absolute',
123 top: '100%',
124 left: 0,
125 right: 0,
126 background: '#fff',
127 border: '1px solid #ccc',
128 listStyle: 'none',
129 margin: 0,
130 padding: 0,
131 maxHeight: '200px',
132 overflowY: 'auto',
133 zIndex: 1000,
134 }}
135 >
136 {isLoading && <li style={{ padding: '8px' }}>Searching...</li>}
137 {!isLoading && results.length === 0 && <li style={{ padding: '8px' }}>No matches found</li>}
138 {!isLoading &&
139 results.map((item, index) => (
140 <li
141 key={index}
142 role="option"
143 aria-selected={highlightedIndex === index}
144 onClick={() => handleItemClick(item)}
145 onMouseEnter={() => setHighlightedIndex(index)}
146 style={{
147 padding: '8px 12px',
148 cursor: 'pointer',
149 backgroundColor: highlightedIndex === index ? '#f3f4f6' : 'transparent',
150 }}
151 >
152 {getLabel(item)}
153 </li>
154 ))}
155 </ul>
156 )}
157 </div>
158 );
159}
Deep Architectural Explanation

The Complete Architecture of an Autocomplete Component

1. In-Memory Request Caching:

Using cacheRef = useRef(new Map<string, T[]>()):

  • When user types "react", fetch data and cache it: cache.set("react", data).
  • If user types "reacts" and then backspaces back to "react", the result is served instantly from cache with zero network latency!
  • Because cacheRef is a ref, mutating it does not cause extra re-renders.

2. Keyboard Navigation Mechanics:

  • ArrowDown: increments highlightedIndex with wrap-around using modulo (% results.length).
  • ArrowUp: decrements highlightedIndex.
  • Enter: selects the currently highlighted option.
  • Escape: dismisses the dropdown menu.
  • e.preventDefault() on arrow keys prevents the browser cursor from jumping to the start/end of the text input.

3. Combobox Accessibility:

  • Input element: role="combobox", aria-autocomplete="list", and aria-expanded={isOpen}.
  • Dropdown: role="listbox".
  • Items: role="option" and aria-selected={highlightedIndex === index}.
Common Interview Traps & Pitfalls
✕

Not preventing default on arrow keys, causing the text cursor to jump to the beginning of the input field.

✕

Forgetting cleanup on the click-outside event listener, leaking memory on unmount.

✕

Failing to handle network race conditions when fast responses arrive after earlier slow requests.

Senior Interviewer Follow-Up Probes
?

How would you bold/highlight matching query substrings within the rendered option labels?

?

How do you support multi-select tagging / token input (like Gmail recipient picker)?

?

How do you scroll the highlighted dropdown item into view automatically using `scrollIntoView`?

Frequently Asked Interview Questions
Why should autocomplete search queries be debounced?

To prevent firing network requests on every keystroke, reducing server load and avoiding bandwidth congestion by waiting for the user to pause typing.

Use AbortController inside useEffect to cancel the in-flight request when the query changes, or track an active request timestamp/ID to discard stale responses.

Because mutating the cache does not require a re-render. Reading and writing to cacheRef.current is synchronous and does not trigger component lifecycle overhead.

role="combobox" with aria-autocomplete="list", aria-expanded (indicating dropdown visibility), and aria-controls linking to the listbox ID.

Attach a mousedown listener to document. Inside the callback, check if containerRef.current.contains(e.target). If false, close the dropdown.

Call e.preventDefault() inside the onKeyDown handler when handling arrow keys, stopping native browser cursor movement.