You’ve added a search field to a React form. Typing produces suggestions, the mouse can select one, and the demo looks finished. Then someone presses ArrowDown, hears nothing, loses focus after a selection, or submits the form by pressing Enter on a highlighted result. That’s when a React JS autocomplete stops being a dropdown and becomes a production accessibility problem.
I’ve shipped autocomplete in production applications, and the failures are rarely caused by filtering an array. They come from incomplete combobox semantics, stale asynchronous responses, keyboard traps, and focus logic that works visually but breaks with assistive technology. In UK public-sector work, this distinction matters especially because GOV.UK guidance connects autocomplete behaviour with keyboard operation, screen-reader announcements and correct ARIA state management. The Government Digital Service has documented accessible-autocomplete testing and provides a React integration path through its accessible-autocomplete/react bundle, establishing a useful reference point for React teams building regulated interfaces (GOV.UK accessible autocomplete documentation).
Table of Contents
- What a React Autocomplete Actually Is
- Controlled vs Uncontrolled Inputs in React
- Building an Accessible Autocomplete From Scratch
- Choosing a Library or Building Your Own
- Debouncing, Async Sources and AI Suggestions
- Common Pitfalls and How to Test Them
- Ship-Ready Checklist for Your Next Build
What a React Autocomplete Actually Is
Start with the semantic model, not the component API. A React autocomplete is an ARIA combobox, a form control that exposes a text input and, when appropriate, a popup containing related choices. The visual dropdown is only one part of the implementation.
The WAI-ARIA Authoring Practices combobox pattern defines autocomplete behaviour through four modes: no autocomplete, list autocomplete with manual selection, list autocomplete with automatic selection, and list with inline autocomplete (WAI-ARIA combobox autocomplete pattern). The popup itself can use different structures, including a listbox, tree or grid. Those choices change the markup, keyboard model and screen-reader announcements, not merely the styling.

The control relationship matters
For the common suggestion-list version, the input is the textbox, the popup is a listbox, and each suggestion is an option. The input should expose the relationship with attributes such as:
aria-expanded, which reflects whether the popup is open.aria-controls, which points to the listbox id.aria-activedescendant, which identifies the currently highlighted option while DOM focus remains on the input.aria-autocomplete, which communicates the chosen completion mode.
The WAI-ARIA pattern describes four autocomplete modes for the combobox itself. In practical React work, teams often use a listbox with manual selection because it gives users explicit control over whether a suggestion becomes the committed value. Automatic and inline completion can be useful, but they require clearer selection and announcement behaviour.
Practical rule: If you can’t describe the input, popup, option relationship and keyboard model before writing JSX, you aren’t ready to choose a library or design the component.
The common mistake is treating the pattern as a styled <ul>. A list can look perfect while the browser and assistive technology receive no reliable information about expansion, active selection or committed value. GOV.UK’s testing notes show that assistive technologies may announce the same control as “edit field”, “edit, combobox” or “combobox”, depending on implementation details (GOV.UK accessibility testing write-up). Every implementation choice below flows from these combobox semantics.
Controlled vs Uncontrolled Inputs in React
Autocomplete exposes the difference between controlled and uncontrolled React inputs quickly. A controlled input keeps the query in React state, so the rendered value is always determined by that state. An uncontrolled input lets the DOM own the value and reads it through a ref when an event occurs.
A controlled version is predictable:
function ControlledAutocomplete({ onQueryChange }) {
const [query, setQuery] = React.useState("");
function handleChange(event) {
const nextQuery = event.target.value;
setQuery(nextQuery);
onQueryChange(nextQuery);
}
return (
<input
value={query}
onChange={handleChange}
role="combobox"
aria-expanded="false"
/>
);
}
That predictability makes resetting, validation, selected-value commits and debounced requests straightforward. The trade-off is that typing updates state and causes React to participate in every change, so filtering, rendering and parent coordination need to remain disciplined. The controlled pattern is especially useful when the parent form needs the current query or selected entity.
An uncontrolled version stays closer to native input behaviour:
function UncontrolledAutocomplete({ onQueryChange }) {
const inputRef = React.useRef(null);
function handleInput() {
const nextQuery = inputRef.current?.value ?? "";
onQueryChange(nextQuery);
}
return (
<input
ref={inputRef}
defaultValue=""
onInput={handleInput}
role="combobox"
aria-expanded="false"
/>
);
}
This reduces the amount of state involved in typing and can suit a simple local filter list. It also makes coordinated resets, validation messages and asynchronous result ownership harder. A parent re-render won’t overwrite the DOM value, but your application must now manage the relationship between that value, selected data and pending requests explicitly.
| Dimension | Controlled | Uncontrolled |
|---|---|---|
| Query ownership | React state | The DOM |
| Reset behaviour | Direct and predictable | Requires a ref or remount |
| Async suggestions | Easy to coordinate with debounced state | More manual |
| Parent form integration | Clear value flow | Needs explicit reads |
| Typing path | React re-renders on changes | Native-style DOM updates |
| Best fit | Remote data and shared form state | Simple local lists |
For a deeper explanation of the broader React pattern, the controlled components guide is useful context. My default is controlled for any React autocomplete that fetches suggestions or coordinates with parent state. Uncontrolled is still valid for a fully local list where reset and validation requirements are modest.
Building an Accessible Autocomplete From Scratch
A from-scratch build should begin with a stable DOM structure. Keep the input mounted, give the popup a stable id, and let the input retain real focus while the highlighted option is represented through aria-activedescendant.

A minimal component might look like this:
function Autocomplete({ options, onSelect }) {
const [query, setQuery] = React.useState("");
const [open, setOpen] = React.useState(false);
const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
const listboxId = "autocomplete-listbox";
const visibleOptions = options.filter((option) =>
option.label.toLowerCase().includes(query.toLowerCase())
);
const activeId =
highlightedIndex >= 0 && visibleOptions[highlightedIndex]
? `autocomplete-option-${visibleOptions[highlightedIndex].id}`
: undefined;
function handleKeyDown(event) {
if (event.key === "ArrowDown") {
event.preventDefault();
setOpen(true);
setHighlightedIndex((index) =>
visibleOptions.length ? (index + 1) % visibleOptions.length : -1
);
}
if (event.key === "ArrowUp") {
event.preventDefault();
setOpen(true);
setHighlightedIndex((index) =>
visibleOptions.length
? (index <= 0 ? visibleOptions.length - 1 : index - 1)
: -1
);
}
if (event.key === "Home" && open && visibleOptions.length) {
event.preventDefault();
setHighlightedIndex(0);
}
if (event.key === "End" && open && visibleOptions.length) {
event.preventDefault();
setHighlightedIndex(visibleOptions.length - 1);
}
if (event.key === "Enter" && open && highlightedIndex >= 0) {
event.preventDefault();
onSelect(visibleOptions[highlightedIndex]);
setQuery(visibleOptions[highlightedIndex].label);
setOpen(false);
setHighlightedIndex(-1);
}
if (event.key === "Escape") {
setOpen(false);
setHighlightedIndex(-1);
}
if (event.key === "Tab") {
setOpen(false);
setHighlightedIndex(-1);
}
}
function handleQueryChange(event) {
setQuery(event.target.value);
setOpen(true);
setHighlightedIndex(-1);
}
return (
<div>
<div role="combobox" aria-expanded={open} aria-haspopup="listbox">
<input
value={query}
onChange={handleQueryChange}
onKeyDown={handleKeyDown}
aria-controls={listboxId}
aria-activedescendant={activeId}
aria-autocomplete="list"
/>
</div>
{open && (
<ul id={listboxId} role="listbox">
{visibleOptions.map((option, index) => (
<li
id={`autocomplete-option-${option.id}`}
key={option.id}
role="option"
aria-selected={index === highlightedIndex}
>
{option.label}
</li>
))}
</ul>
)}
<div aria-live="polite" aria-atomic="true">
{open ? `${visibleOptions.length} results available` : ""}
</div>
</div>
);
}
Keep focus on the textbox
The active descendant changes as the user presses ArrowDown or ArrowUp, but actual DOM focus stays on the input. Don’t add tabIndex="0" to every option and don’t move focus into the list unless you’ve deliberately chosen a different interaction model. Multiple tabbable elements create a second navigation system and often leave keyboard users trapped between the input and popup.
Home and End need special handling. Without it, the browser moves the caret to the start or end of the query, which is correct for a normal text field but wrong when the user intends to move through the open suggestion list. Enter should commit only when an option is highlighted. Otherwise, it should retain normal form behaviour where that is appropriate.
The polite live region reports result changes without interrupting typing. Keep announcements concise and update them when the result set changes, not on every internal render. For broader component-level accessibility principles, see this guide to accessible web components.
This short walkthrough demonstrates the interaction model visually, but a working example still needs testing across browsers and assistive technology:
A skip link won’t repair an autocomplete, but it belongs in the same page-level keyboard plan. The WCAG skip link guide provides useful context for ensuring keyboard users can reach and leave the control within a sensible document structure.
Two React details cause disproportionate trouble. First, don’t conditionally replace the input with a loading element while fetching, because focus disappears. Second, reset highlightedIndex whenever the query changes. Otherwise aria-activedescendant can point at an option that no longer exists, leaving assistive technology with a stale reference.
Choosing a Library or Building Your Own
The right choice depends less on brand preference than on how much behaviour your team is prepared to own. A headless primitive gives you interaction logic while leaving markup and styling in your hands. A styled component library gets a usable control on screen quickly, but its DOM and visual assumptions can be harder to change. A bespoke build gives maximum control and maximum responsibility.
| Approach | Bundle cost | ARIA maturity | Theming | Async support | Best for |
|---|---|---|---|---|---|
| Headless primitive | Usually focused, with package overhead depending on imports | Strong starting point, still requires verification | High | Flexible | Bespoke product interfaces |
| Styled library | Includes component and styling infrastructure | Varies by component and version | Moderate to constrained | Usually supported | Admin tools and established design systems |
| Bespoke build | Only your implementation | Entirely your responsibility | Complete | Complete | Teams with strong combobox expertise |
Headless primitives
Downshift, React Aria’s combobox tools and Ariakit can reduce the amount of keyboard and ARIA plumbing you write. You still own the rendered markup, option content, positioning and integration with your data source. That makes these tools attractive when the design is distinctive or when a product needs behaviour that a styled component doesn’t expose cleanly.
Don’t mistake a primitive for an accessibility certificate. You must still inspect the generated attributes, test the keyboard matrix and verify announcements with real assistive technology. A primitive can make the correct path easier without making an incorrect composition impossible.
Styled components and custom builds
MUI Autocomplete, Ant Design AutoComplete and Mantine’s related controls are practical choices when the application already uses that ecosystem. They can shorten delivery and provide responsive defaults, but replacing internal structure or fixing a library-specific ARIA edge case may take more effort than expected.
A bespoke implementation makes sense when the result shape, interaction model or rendering constraints are unusual. It doesn’t make sense because the first demo is easy. Teams that choose scratch should budget for focus management, portal behaviour, async cancellation, screen-reader testing and maintenance after the original author moves on.
If you’re evaluating component tooling alongside AI-assisted development, AI productivity tips for freelancers can help frame where generated code saves time and where human review remains essential. DOM Studio is another option in the wider component ecosystem. Its headless <dom-autocomplete> provides a framework-agnostic autocomplete primitive with suggestion-list events, while its Vue integration is aimed at teams that want prebuilt ARIA and keyboard behaviour rather than another bespoke implementation.
My rule of thumb is simple: headless wins for bespoke designs, styled libraries win for established admin interfaces, and scratch wins only when the team already understands the ARIA and keyboard contract.
Debouncing, Async Sources and AI Suggestions
Remote suggestions need a request lifecycle, not just a delayed fetch. The basic sequence is input, debounce, request, response, state update. Every transition needs an owner so an older response can’t overwrite what the user has typed since.

A small trailing-edge hook is enough for the first layer:
function useDebouncedValue(value, delay) {
const [debouncedValue, setDebouncedValue] = React.useState(value);
React.useEffect(() => {
const timer = window.setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => window.clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
For ordinary typing, a trailing delay around 200 to 300 milliseconds is a sensible starting point. A shorter delay can suit compact inputs such as country-code searches. Leading-edge debounce usually feels wrong for search because it sends the first character immediately and then suppresses the query that contains the useful context. Measure the interaction in your application rather than treating any delay as universal.
Cancel old work
Abort requests when the query changes or the component unmounts:
function useSuggestions(query) {
const [state, setState] = React.useState({
items: [],
loading: false,
error: null,
});
React.useEffect(() => {
const normalised = query.trim().toLowerCase();
if (!normalised) {
setState({ items: [], loading: false, error: null });
return;
}
const controller = new AbortController();
async function load() {
setState((current) => ({ ...current, loading: true, error: null }));
try {
const response = await fetch(
`/api/suggestions?q=${encodeURIComponent(normalised)}`,
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error("Suggestion request failed");
}
const items = await response.json();
setState({ items, loading: false, error: null });
} catch (error) {
if (error.name !== "AbortError") {
setState({ items: [], loading: false, error });
}
}
}
load();
return () => controller.abort();
}, [query]);
return state;
}
An abort prevents many late updates, but a request-id check is useful as a belt-and-braces fallback. Increment an id for each request and update state only when the response belongs to the latest id. Add a small in-memory cache keyed by the normalised query, so backspace-and-retype cycles can reuse known results instead of calling the endpoint again.
AI suggestions add a second kind of uncertainty. Give them a distinct label or icon, let users dismiss them, and don’t replace a manually typed value. Streamed results should update the list without moving focus, and the loading state belongs in the accessibility tree through aria-busy or a concise live announcement. Keep the race-condition checklist visible during implementation:
- Ownership: Older responses can’t overwrite newer queries.
- Cancellation: Unmounting cancels pending work.
- Loading: The input remains usable while results load.
- Announcement: Errors and result changes are communicated without noisy repetition.
- Focus: Streaming never steals focus from the textbox.
- Cache: Normalised queries reuse safe, current results.
Common Pitfalls and How to Test Them
A React autocomplete can look correct and still fail as a form control. The failures usually appear when keyboard focus, combobox state and asynchronous results fall out of sync.

The failures I look for first
- The popup isn’t announced as expanded: The list appears after typing, but
aria-expandedremains false until another render. Bind it directly to the state that controls actual visibility. - The active option is not managed: Adding
tabIndexto each option or moving focus into the list breaks the combobox interaction. Keep focus on the input and updatearia-activedescendant. - Escape closes only visually: The menu disappears while
aria-expandedremains true. Derive both presentation and accessibility state from the same open-state value. - Home and End move the caret: Handle these keys only while the popup is open and contains options. Otherwise preserve normal text editing.
- Enter submits unexpectedly: Prevent form submission when Enter commits a highlighted option. If no option is active, preserve normal form behaviour unless the product defines another rule.
- Mouse hover overrides keyboard intent: Pointer movement can change the active option while the user is using arrow keys. Define how pointer and keyboard highlighting interact, then test mixed input.
- Late results steal focus: A response arrives after the user tabs away and a render calls
.focus(). Data updates must never refocus the input. - Portals disrupt navigation: A popup rendered elsewhere in the DOM can affect focus order, dismissal handling and outside-click logic. Test the production portal arrangement rather than an isolated story.
The GOV.UK accessible autocomplete criteria provides a useful reference for the combobox contract, including keyboard operation, focus behaviour and name, role and value exposure. Treat those behaviours as implementation requirements, not optional library details.
Test the interaction, not just the snapshot
Use Testing Library for state transitions and key sequences. Assert aria-expanded, aria-controls, aria-activedescendant, aria-selected and live-region output. Run the component inside a real form as well, since Enter, Tab and validation can behave differently at an actual submit boundary.
Manual checks expose failures automated assertions miss. Test keyboard-only operation in Chrome, then run NVDA with Firefox and VoiceOver with Safari. The screen-reader testing guide helps organise those passes around announcements, focus and selection feedback.
Repeat the same sequence after every interaction change: tab into the field, type, press ArrowDown, ArrowUp, Home, End, Enter and Escape, click an option, then tab out. Cover empty results, loading, errors, long labels and a selection made immediately before a response arrives. A passing snapshot is not enough if the live combobox state is wrong.
Ship-Ready Checklist for Your Next Build
Put this checklist in the implementation ticket. An autocomplete is an accessibility-critical form control, so its ARIA contract and keyboard behavior need explicit ownership.
Choose the ownership model
- Use a maintained headless primitive when you need custom markup without owning every keyboard transition.
- Use a styled component when the application has a compatible design system and its generated DOM meets your accessibility requirements.
- Build from scratch only when the data shape or interaction is unusual and the team can maintain the full contract.
Verify the ARIA contract
- Combobox: The input or wrapper exposes the intended combobox semantics.
- Expansion:
aria-expandedalways matches popup visibility. - Relationship:
aria-controlspoints to a stable listbox id. - Active option:
aria-activedescendantis empty or references a mounted option. - Popup: The suggestion container uses
role="listbox". - Options: Each suggestion uses
role="option"and exposesaria-selectedcorrectly. - Completion mode:
aria-autocompletematches the implemented behavior.
Run the keyboard matrix
ArrowUp and ArrowDown should move predictably. Home and End should move through the open list. Enter must commit the active option without accidentally submitting the form. Escape closes the popup without committing. Tab closes it and preserves normal focus movement.
Complete pre-launch checks
Run an axe-core scan, then test with NVDA and Firefox and with VoiceOver and Safari. Verify debounce behavior for async sources, cancel requests on unmount, preserve focus during updates, handle late responses, and respect reduced-motion preferences for caret or highlight animations.
The decision rule is straightforward: if your team cannot commit the ARIA contract and keyboard matrix in tests, choose a maintained library instead of rolling your own.
DOM Studio offers headless UI primitives, including an autocomplete component with suggestion-list events and built-in accessibility behavior for teams that do not want to reimplement combobox semantics. Visit DOM Studio to review the component approach for a production interface.
