Machine-Coding Round: Build an Accessible Autocomplete in React
The machine-coding round is one of the most decisive stages in modern front-end interviews. Companies use it to evaluate whether a candidate can translate ambiguous UI specifications into working, accessible, and resilient components under a strict 45-to-60-minute time constraint.
Building an Autocomplete (or search-as-you-type Combobox) is the quintessential machine-coding problem. It tests virtually every core front-end engineering discipline: state management, input handling, asynchronous race conditions, debouncing, keyboard navigation, and WAI-ARIA accessibility compliance.
This guide walks through building an enterprise-grade Autocomplete component in React and TypeScript, targeting developers with 0 to 5 years of experience preparing for front-end machine-coding rounds.
| # | Implementation Milestone | Level |
|---|---|---|
| 1 | Clarify Requirements & Scope Component Contract | Junior |
| 2 | Component Architecture & State Management Plan | Junior |
| 3 | Debounced Data Fetching & Cancellation via AbortController | Mid |
| 4 | WAI-ARIA 1.2 Combobox Specification & Keyboard Navigation | Mid |
| 5 | Handling Edge Cases: Loading, Empty State, Errors, and Outside Clicks | Mid |
| 6 | Unit & Interaction Testing with React Testing Library | Mid |
| 7 | Performance Extensions: Client Caching & Virtualization | Senior |
1. Requirements and Clarifying Questions
Before writing a single line of code in an interview, take 3 to 5 minutes to align on requirements. Strong candidates never assume behavior; they ask targeted clarifying questions.
Clarifying Questions to Ask the Interviewer:
- Data Source: Is the dataset local in-memory or queried asynchronously over an HTTP endpoint? (Assume asynchronous remote API).
- Debounce Behavior: Should queries be debounced, and what delay is expected? (Assume 300ms debounce).
- Selection Behavior: Does selecting an option trigger an external callback, and does it populate the input text? (Assume calling
onSelect(item)and updating input). - Keyboard Navigation: What keys must be supported? (ArrowDown, ArrowUp, Enter to select, and Escape to close).
- Accessibility Standard: Does the component need to adhere to the WAI-ARIA Combobox pattern? (Yes, full WAI-ARIA 1.2 Combobox compliance).
- Error & Empty States: How should network errors and zero-result queries be represented? (Inline status messages).
2. Suggested Time Budget (45-Minute Round)
Managing time effectively prevents running out of time before implementing critical accessibility or edge cases:
- 00:00 – 05:00 (5 min): Requirements gathering, edge-case identification, and component contract design.
- 05:00 – 15:00 (10 min): Component scaffolding, base JSX, and internal state setup.
- 15:00 – 25:00 (10 min): Debounced asynchronous querying with
AbortControllercancellation. - 25:00 – 35:00 (10 min): Keyboard navigation (Arrow keys, Enter, Escape) and ARIA attributes.
- 35:00 – 40:00 (5 min): Handling edge cases (click outside, loading spinner, empty states, error boundaries).
- 40:00 – 45:00 (5 min): Writing a verification test and discussing scalability extensions.
3. Component & State Plan
The Props Contract:
export interface AutocompleteProps<T> {
fetchSuggestions: (query: string, signal: AbortSignal) => Promise<T[]>;
renderItem: (item: T, isHighlighted: boolean) => React.ReactNode;
getItemKey: (item: T) => string;
getItemLabel: (item: T) => string;
onSelect: (item: T) => void;
placeholder?: string;
debounceMs?: number;
}
State Breakdown:
query(string): The current text value in the search input.items(T[]): Array of suggestions returned by the remote service.isOpen(boolean): Controls dropdown visibility.highlightedIndex(number): 0-based index of the currently highlighted option (-1 if none).isLoading(boolean): Indicates an active in-flight request.error(string | null): Tracks fetch failures.
4. Step-by-Step Implementation
Step 1: Debouncing and Request Cancellation
A common pitfall is triggering a network call on every keystroke or allowing slower, older responses to overwrite faster, newer responses. We solve this by combining a custom debounce delay with an AbortController inside a useEffect hook.
Step 2: Keyboard Navigation & Focus Management
According to the W3C WAI-ARIA Combobox pattern, DOM focus remains permanently inside the <input> element while the user navigates options. We use aria-activedescendant set to the ID of the highlighted <li> element.
- ArrowDown: Increments
highlightedIndex, wrapping to 0 at the end. - ArrowUp: Decrements
highlightedIndex, wrapping to the bottom. - Enter: Selects the currently highlighted item.
- Escape: Closes the dropdown menu and resets highlighted index.
Step 3: Outside Click Dismissal
We attach a mousedown listener to the document that detects if an interaction occurred outside the combobox root container, closing the dropdown menu cleanly.
5. The Complete Production-Grade Implementation
Here is the complete, runnable React component in TypeScript:
import React, { useState, useEffect, useRef, useId } from 'react';
export interface AutocompleteProps<T> {
fetchSuggestions: (query: string, signal: AbortSignal) => Promise<T[]>;
renderItem?: (item: T, isHighlighted: boolean) => React.ReactNode;
getItemKey: (item: T) => string;
getItemLabel: (item: T) => string;
onSelect: (item: T) => void;
placeholder?: string;
debounceMs?: number;
}
export function Autocomplete<T>({
fetchSuggestions,
renderItem,
getItemKey,
getItemLabel,
onSelect,
placeholder = 'Search...',
debounceMs = 300,
}: AutocompleteProps<T>) {
const [query, setQuery] = useState('');
const [items, setItems] = useState<T[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listboxId = useId();
// Handle outside clicks to dismiss dropdown
useEffect(() => {
const handleOutsideClick = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
setHighlightedIndex(-1);
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => document.removeEventListener('mousedown', handleOutsideClick);
}, []);
// Debounced asynchronous fetch with AbortController
useEffect(() => {
if (!query.trim()) {
setItems([]);
setIsOpen(false);
setIsLoading(false);
setError(null);
return;
}
setIsLoading(true);
setError(null);
const controller = new AbortController();
const timeoutId = setTimeout(async () => {
try {
const results = await fetchSuggestions(query, controller.signal);
setItems(results);
setIsOpen(true);
setHighlightedIndex(-1);
} catch (err: any) {
if (err.name !== 'AbortError') {
setError('Failed to fetch suggestions');
setItems([]);
}
} finally {
setIsLoading(false);
}
}, debounceMs);
return () => {
clearTimeout(timeoutId);
controller.abort();
};
}, [query, fetchSuggestions, debounceMs]);
const handleSelectItem = (item: T) => {
setQuery(getItemLabel(item));
setIsOpen(false);
setHighlightedIndex(-1);
onSelect(item);
inputRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isOpen || items.length === 0) {
if (e.key === 'ArrowDown') {
setIsOpen(true);
}
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));
break;
case 'Enter':
e.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < items.length) {
handleSelectItem(items[highlightedIndex]);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
setHighlightedIndex(-1);
break;
}
};
const activeOptionId =
highlightedIndex >= 0 && items[highlightedIndex]
? `${listboxId}-option-${getItemKey(items[highlightedIndex])}`
: undefined;
return (
<div ref={containerRef} style={{ position: 'relative', width: '320px' }}>
<input
ref={inputRef}
type="text"
role="combobox"
value={query}
placeholder={placeholder}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => items.length > 0 && setIsOpen(true)}
onKeyDown={handleKeyDown}
aria-expanded={isOpen}
aria-autocomplete="list"
aria-controls={listboxId}
aria-activedescendant={activeOptionId}
style={{ width: '100%', padding: '8px 12px', boxSizing: 'border-box' }}
/>
{isOpen && (
<ul
id={listboxId}
role="listbox"
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
maxHeight: '240px',
overflowY: 'auto',
margin: 0,
padding: 0,
listStyle: 'none',
border: '1px solid #ccc',
backgroundColor: '#fff',
zIndex: 1000,
}}
>
{isLoading && <li style={{ padding: '8px 12px' }}>Loading suggestions...</li>}
{error && <li style={{ padding: '8px 12px', color: 'red' }}>{error}</li>}
{!isLoading && !error && items.length === 0 && (
<li style={{ padding: '8px 12px', color: '#666' }}>No results found</li>
)}
{!isLoading &&
!error &&
items.map((item, index) => {
const isHighlighted = index === highlightedIndex;
const itemId = `${listboxId}-option-${getItemKey(item)}`;
return (
<li
key={getItemKey(item)}
id={itemId}
role="option"
aria-selected={isHighlighted}
onClick={() => handleSelectItem(item)}
onMouseEnter={() => setHighlightedIndex(index)}
style={{
padding: '8px 12px',
cursor: 'pointer',
backgroundColor: isHighlighted ? '#e6f7ff' : '#fff',
}}
>
{renderItem ? renderItem(item, isHighlighted) : getItemLabel(item)}
</li>
);
})}
</ul>
)}
</div>
);
}
6. Accessibility Deep Dive (WAI-ARIA Combobox 1.2)
To earn maximum points in an interview, explicitly explain how your component satisfies accessibility standards:
role="combobox"on<input>: Declares the text input as the controller of a popup listbox.aria-expanded: Dynamically reflects whether the suggestion dropdown is visible (trueorfalse).aria-autocomplete="list": Informs screen readers that input will present a collection of matching items.aria-controls: Points directly to theidof the<ul>element holding the options.aria-activedescendant: Crucial for virtual focus. The DOM focus stays on the<input>so the user can continue typing, but assistive technology announces the active option via this attribute.role="listbox"&role="option": Establishes parent-child semantics for the dropdown.
7. Edge Cases Handled
During evaluation, interviewers inspect how your implementation responds to hostile conditions:
- Network Race Conditions: If request A is sent, followed rapidly by request B,
controller.abort()cancels request A, preventing out-of-order state overwrites. - Rapid Typing & Clearing: When the user hits Backspace and clears the field,
clearTimeoutprevents pending debounce queries and immediately clears the item list. - Empty Results: Renders a helpful
No results foundmessage rather than collapsing silently. - Network Failures: Catches fetch rejections, filters out expected
AbortErrorinstances, and renders a non-blocking error banner. - Blur / Outside Click: Clicking anywhere outside the component gracefully dismisses the dropdown without losing input state.
8. Unit Testing with React Testing Library
Strong candidates show interviewers how to verify interactions with automated tests:
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Autocomplete } from './Autocomplete';
test('navigates suggestions and selects option via keyboard', async () => {
const user = userEvent.setup();
const mockFetch = jest.fn().mockResolvedValue(['Apple', 'Apricot', 'Avocado']);
const handleSelect = jest.fn();
render(
<Autocomplete
fetchSuggestions={mockFetch}
getItemKey={(item) => item}
getItemLabel={(item) => item}
onSelect={handleSelect}
/>
);
const input = screen.getByRole('combobox');
await user.type(input, 'Ap');
// Wait for debounce and fetch
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
const options = screen.getAllByRole('option');
expect(options).toHaveLength(3);
// Navigate down with keyboard
fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(options[0]).toHaveAttribute('aria-selected', 'true');
// Select via Enter
fireEvent.keyDown(input, { key: 'Enter' });
expect(handleSelect).toHaveBeenCalledWith('Apple');
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
9. Architectural Extensions to Mention to the Interviewer
When you finish early, proactively offer these production extensions to demonstrate senior-level judgment:
- In-Memory Cache (LRU): Wrap
fetchSuggestionswith a memoization map to return instant results for previously typed queries without hitting the network. - List Virtualization (
react-window): If the API returns hundreds of items, virtualize the listbox DOM nodes to maintain 60 FPS scrolling and low memory usage. - Custom Highlighting: Highlight matching substrings within the option text using regex splits or
<mark>tags. - Portal Rendering: Render the dropdown into
document.bodyviacreatePortalwith dynamic floating positioning (such as Floating UI) to prevent dropdown clipping inside containers withoverflow: hidden.
Preparation Checklist
- Practise writing the WAI-ARIA Combobox 1.2 attribute structure (
combobox,listbox,option,aria-activedescendant) from memory. - Implement debounced data fetching combined with
AbortControllercancellation in under 10 minutes. - Implement ArrowDown, ArrowUp, Enter, and Escape keyboard navigation on a whiteboard or scratchpad.
- Write an outside-click listener hook using
mousedownand container ref containment. - Build a React Testing Library test verifying both mouse click and keyboard selection.
- Practise explaining the difference between DOM focus and virtual focus (
aria-activedescendant) out loud.
Conclusion
Building an Autocomplete under time constraints demonstrates comprehensive front-end competency. By methodically addressing requirements, separating concerns into debounced asynchronous queries, adhering strictly to WAI-ARIA combobox standards, and handling edge cases with AbortController, you demonstrate the poise and architectural maturity required of top-tier engineers.