) => {
setRawInput(value);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setStableQuery(value.trim());
}, delayMs);
};
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
return { rawInput, stableQuery, updateInput };
}
**Why this design:** Separating raw input from stable query prevents UI jitter during typing. The cleanup effect guarantees no memory leaks during unmounts. Configurable delay allows tuning based on API latency or network conditions.
### Step 2: Orchestrate Async Data & Cancellation
Stale requests overwrite fresh results, causing UI flicker and incorrect selections. The data hook must manage request lifecycle, loading states, and error boundaries.
```typescript
import { useState, useEffect, useCallback } from 'react';
interface SearchResult {
identifier: string;
displayText: string;
metadata?: Record<string, unknown>;
}
export function useSearchOrchestrator(query: string) {
const [results, setResults] = useState<SearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState('Ready for input.');
const fetchSuggestions = useCallback(async (searchTerm: string) => {
if (!searchTerm) {
setResults([]);
setStatusMessage('Ready for input.');
return;
}
const controller = new AbortController();
setIsLoading(true);
setError(null);
setStatusMessage('Fetching suggestions...');
try {
const response = await fetch(
`/v1/catalog/search?q=${encodeURIComponent(searchTerm)}`,
{ signal: controller.signal }
);
if (!response.ok) throw new Error('Network response failed');
const data: SearchResult[] = await response.json();
setResults(data);
setStatusMessage(
data.length > 0
? `${data.length} results found. Navigate with arrow keys.`
: 'No matching results.'
);
} catch (err) {
if ((err as Error).name !== 'AbortError') {
setError('Failed to retrieve suggestions.');
setStatusMessage('Search failed. Please try again.');
}
} finally {
setIsLoading(false);
}
return () => controller.abort();
}, []);
useEffect(() => {
const cleanup = fetchSuggestions(query);
return cleanup;
}, [query, fetchSuggestions]);
return { results, isLoading, error, statusMessage, setStatusMessage };
}
Why this design: AbortController guarantees only the latest request resolves. The cleanup function attached to useEffect cancels pending requests on query change or unmount. Status messages are decoupled from UI rendering, enabling consistent screen reader announcements.
Step 3: Implement Keyboard Navigation Contract
Autocomplete must function without pointer devices. The navigation hook should track active indices, handle directional keys, and prevent default browser behavior where necessary.
import { useState, useCallback, useRef } from 'react';
interface NavOptions {
itemCount: number;
onSelect: (index: number) => void;
onClose: () => void;
}
export function useKeyboardNavigation({ itemCount, onSelect, onClose }: NavOptions) {
const [activeIndex, setActiveIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const resetNavigation = useCallback(() => {
setActiveIndex(-1);
}, []);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (itemCount === 0) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
setActiveIndex((prev) => Math.min(prev + 1, itemCount - 1));
break;
case 'ArrowUp':
event.preventDefault();
setActiveIndex((prev) => Math.max(prev - 1, 0));
break;
case 'Enter':
if (activeIndex >= 0) {
event.preventDefault();
onSelect(activeIndex);
}
break;
case 'Escape':
event.preventDefault();
onClose();
break;
}
},
[itemCount, activeIndex, onSelect, onClose]
);
return { activeIndex, containerRef, resetNavigation, handleKeyDown };
}
Why this design: Encapsulating keyboard logic in a dedicated hook isolates event handling from presentation. preventDefault() is scoped to relevant keys only, preserving native form submission when appropriate. The active index resets cleanly on close or empty results.
Step 4: Assemble the Accessible Component
The final layer binds state, keyboard contracts, and ARIA attributes to semantic HTML. Focus remains on the input element while aria-activedescendant communicates selection to assistive technologies.
import { useRef } from 'react';
import { useBufferedInput } from './useBufferedInput';
import { useSearchOrchestrator } from './useSearchOrchestrator';
import { useKeyboardNavigation } from './useKeyboardNavigation';
export function SearchAutocomplete() {
const { rawInput, stableQuery, updateInput } = useBufferedInput('', 250);
const { results, isLoading, statusMessage, setStatusMessage } = useSearchOrchestrator(stableQuery);
const inputRef = useRef<HTMLInputElement>(null);
const handleSelect = (index: number) => {
const selected = results[index];
updateInput(selected.displayText);
setStatusMessage(`Selected ${selected.displayText}.`);
};
const handleClose = () => {
setStatusMessage('Suggestions closed.');
};
const { activeIndex, handleKeyDown, resetNavigation } = useKeyboardNavigation({
itemCount: results.length,
onSelect: handleSelect,
onClose: handleClose,
});
const listId = 'search-results-list';
const activeItemId = activeIndex >= 0 ? `result-${results[activeIndex].identifier}` : undefined;
return (
<div className="search-widget" ref={useRef<HTMLDivElement>(null)}>
<label htmlFor="catalog-search" className="search-label">
Search catalog
</label>
<input
id="catalog-search"
ref={inputRef}
type="search"
value={rawInput}
onChange={(e) => updateInput(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => setTimeout(resetNavigation, 150)}
aria-autocomplete="list"
aria-controls={listId}
aria-expanded={results.length > 0}
aria-activedescendant={activeItemId}
autoComplete="off"
className="search-input"
/>
<div aria-live="polite" className="sr-only" role="status">
{statusMessage}
</div>
{isLoading && <p className="search-status">Loading results...</p>}
{results.length > 0 && (
<ul id={listId} role="listbox" className="search-dropdown">
{results.map((item, idx) => (
<li
key={item.identifier}
id={`result-${item.identifier}`}
role="option"
aria-selected={idx === activeIndex}
className={`search-option ${idx === activeIndex ? 'is-active' : ''}`}
onMouseDown={() => handleSelect(idx)}
>
{item.displayText}
</li>
))}
</ul>
)}
</div>
);
}
Why this design:
aria-activedescendant keeps focus on the input while informing the accessibility tree of the active option. This prevents focus loss bugs common in dropdown implementations.
aria-live="polite" announces state changes without interrupting user flow.
onBlur with a micro-delay prevents premature closure when clicking options.
- Semantic roles (
listbox, option) align with WAI-ARIA authoring practices, ensuring compatibility across NVDA, VoiceOver, and JAWS.
Pitfall Guide
1. Race Conditions from Rapid Typing
Explanation: Firing a request on every keystroke causes older, slower responses to overwrite newer results. The UI displays stale data, and users select incorrect items.
Fix: Implement debouncing at the input layer and pair it with AbortController at the fetch layer. Only resolve the latest request.
2. Focus Stealing on Dropdown Interaction
Explanation: Moving DOM focus to <li> elements breaks the tab order and forces screen readers to re-announce the entire list on every keypress.
Fix: Keep focus on the <input> and use aria-activedescendant to track the active option. Update the attribute value as the user navigates.
3. Missing or Aggressive Live Region Updates
Explanation: Omitting aria-live leaves screen reader users unaware of loading states or empty results. Using aria-live="assertive" interrupts ongoing speech, causing frustration.
Fix: Use aria-live="polite" for status updates. Debounce rapid status changes to prevent announcement spam.
4. Hardcoded Debounce Delays
Explanation: A fixed 250ms delay works on fast networks but feels sluggish on high-latency connections or when API response times vary.
Fix: Make delay configurable. Consider adaptive debouncing that increases delay during peak load or decreases it for cached/local searches.
5. Ignoring Mobile and Zoom Layouts
Explanation: Fixed-width dropdowns overflow on small screens. Zooming the page breaks absolute positioning, causing misalignment between input and results.
Fix: Use relative positioning with max-width: 100%. Implement scroll containers with overflow-y: auto. Test at 200% and 400% zoom levels.
Explanation: Submitting empty strings or whitespace triggers unnecessary API calls, wasting bandwidth and server resources.
Fix: Trim input before triggering fetch. Return early if the query is empty. Reset results and status immediately.
Explanation: Calling preventDefault() on the Enter key unconditionally prevents form submission when no option is selected.
Fix: Only prevent default when activeIndex >= 0. Allow Enter to propagate to the form element otherwise.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| < 50 suggestions | Standard DOM rendering | Simplicity outweighs optimization needs | Low |
| 50-500 suggestions | Debounced fetch + scroll container | Balances performance and UX | Medium |
| 500+ suggestions | Windowed virtualization (e.g., react-window) | Prevents DOM thrash and memory leaks | High (dev time) |
| High-latency network | Adaptive debounce (300-500ms) | Reduces request storms without feeling sluggish | Low |
| Enterprise compliance | Full WAI-ARIA + automated testing | Meets legal and audit requirements | Medium |
Configuration Template
// search-widget.config.ts
export interface SearchWidgetConfig {
debounceMs: number;
apiEndpoint: string;
minQueryLength: number;
maxResults: number;
enableVirtualization: boolean;
virtualizationThreshold: number;
ariaLivePolicy: 'polite' | 'assertive';
onSelection: (item: SearchResult) => void;
onError: (error: Error) => void;
}
export const defaultConfig: SearchWidgetConfig = {
debounceMs: 250,
apiEndpoint: '/v1/catalog/search',
minQueryLength: 2,
maxResults: 10,
enableVirtualization: false,
virtualizationThreshold: 100,
ariaLivePolicy: 'polite',
onSelection: () => {},
onError: (err) => console.error('Search failed:', err),
};
Quick Start Guide
- Install dependencies: Ensure React 18+ and TypeScript are configured. No external libraries are required for the base implementation.
- Copy the hooks: Place
useBufferedInput, useSearchOrchestrator, and useKeyboardNavigation in your utilities directory.
- Adapt the API endpoint: Update the fetch URL in
useSearchOrchestrator to match your backend contract. Ensure the response returns an array of objects with identifier and displayText.
- Mount the component: Import
SearchAutocomplete into your form or page. Wrap it in a container with appropriate spacing and responsive constraints.
- Validate accessibility: Run
axe-core in your test suite. Manually test keyboard navigation and screen reader announcements using NVDA or VoiceOver.