Representative interview topic

How Would You Build an Accessible Autocomplete Component?

FrontendHard
Offer.cc Editorial TeamPublished Updated

Question

Given a search API, design a reusable autocomplete component. It should request at most 10 suggestions after the user stops typing for 300 ms, and the API has a 400 ms p95 latency. On desktop and mobile, it must support keyboard, mouse, touch, screen readers, and IME input, and it must never show results for an older query during rapid typing. Explain the component API, state model, asynchronous requests, accessibility semantics, caching, error handling, and testing.

Prompt and Scope

Given a search API, design a reusable autocomplete component. It should request at most 10 suggestions after the user stops typing for 300 ms, and the API has a 400 ms p95 latency. On desktop and mobile, it must support keyboard, mouse, touch, screen readers, and IME input, and it must never show results for an older query during rapid typing. Explain the component API, state model, asynchronous requests, accessibility semantics, caching, error handling, and testing.

Those numbers are interview assumptions. The base scope is the frontend component; server-side ranking, spell correction, personalization, multi-select, and infinite scrolling are out of scope. A result may be plain text or a custom-rendered row, but every result must have a stable ID and a readable label. The component follows an editable combobox pattern with a single-select suggestion list.

This question fits frontend and full-stack roles. Its core skills are browser UI, asynchronous state, and accessible interaction, so the category is frontend. The existing Trie article treats prefix lookup as a data-structure problem and mentions Top K only as a follow-up. Here the search API is a given dependency; the independent problem is client-side races, focus semantics, IME behavior, and a verifiable interaction contract.

What the Interviewer Evaluates

The first signal is whether the candidate separates request reduction from correctness. A 300 ms debounce reduces requests during continuous typing, but it does not stop an older request from returning after a newer one. A strong answer combines cancellation with a monotonically increasing request sequence and lets only the latest request for the current query commit results.

The second signal is whether accessibility semantics form a complete contract. Visual styling alone cannot relate the input, popup, and options. DOM focus should remain on the input while aria-activedescendant identifies the active option. aria-expanded, aria-controls, aria-autocomplete, listbox, option, and aria-selected must change consistently with visible state.

The third signal is an accurate model of text input. Chinese, Japanese, and other IMEs produce several updates during one composition session. Searching every intermediate string creates irrelevant requests and can interfere with candidate selection. The component should track composition and schedule a search for the committed value after compositionend.

The fourth signal is whether state and UI can be proven consistent. A single loading boolean and an array cannot clearly express a short query, loading, success, empty results, failure, and a closed popup. A strong answer defines transitions, stable option IDs, recovery behavior, and the rule for the active option after results change, then tests those invariants with out-of-order responses, a keyboard, and a screen reader.

Clarifying Questions Before Answering

  • What happens after a suggestion is chosen? If it only fills the input, call onSelect and close the list. If it navigates immediately, navigation failure and input restoration on return need their own contract. The base design fills the input and emits a callback.
  • Who controls the input value? A form may require value and onValueChange; a standalone search box may support an uncontrolled initial value. The component must not switch modes during its lifetime.
  • Are results plain text? Rich results require renderItem, but getKey and getLabel must still provide a stable ID and accessible name. Accepting arbitrary HTML increases injection risk.
  • How many characters start a search? The base default is two. Below that threshold, cancel pending work, close the list, and clear the active option. Showing history for an empty query would require a different aria-autocomplete and cache policy.
  • Does Tab select the active suggestion? In the base design, no; Tab leaves the widget. If the product insists that Tab accepts a suggestion, that behavior needs explicit user communication and separate testing rather than silently overriding normal focus movement.
  • Should old results remain after an error? This design clears them and shows a retryable error so users cannot mistake an old query's suggestions for the current query. An offline-first product could show a cache entry marked as possibly stale, but that is a different contract.

30-Second Answer Framework

“I would separate customizable rendering from a headless state controller. The controller owns the query, request status, popup state, active option ID, IME state, and latest request sequence. After committed input, it debounces for 300 milliseconds and cancels the previous request; a response must still match both the latest sequence and current query before it can commit. The input uses combobox semantics and keeps DOM focus, while arrow keys update aria-activedescendant, Enter selects, and Escape closes. Searching waits for composition to end, and a separate status message announces loading, result count, and no results. I would verify it with reordered network responses, a keyboard matrix, IME input, a screen reader, and cache invalidation.”

Step-by-Step Deep Dive

Step 1: Define the boundary and public API

Search algorithms belong to the server. The component receives a query function and rendering policy. A framework-neutral interface could look like this:

typescript
Autocomplete<T>({
  value,
  onValueChange,
  fetchSuggestions(query, signal),
  getKey(item),
  getLabel(item),
  renderItem,
  onSelect,
  minChars = 2,
  limit = 10,
  debounceMs = 300,
})

fetchSuggestions accepts an AbortSignal so the caller can propagate one cancellation chain to the network. getKey supplies a stable option DOM ID, and getLabel supplies both fill text and an accessible name. Custom rendering must not replace the keyboard, focus, or selection semantics. If the component exposes value-change callbacks, include a structured reason such as input, selection, or clear so consumers do not infer user intent from string changes.

Step 2: Constrain rendering with a state machine

The core state is:

query status = idle | loading | success | empty | error items isOpen activeId selectedItem isComposing latestRequestSeq

query is the current input text; selectedItem is the confirmed selection. They are different facts and should not share one field. The list can open only when the query meets the minimum length, the input is still in an interactive context, and the status has results or feedback to display. Clear activeId when a new query starts. When new results arrive, retain an old active ID only if it still exists; otherwise clear it so aria-activedescendant never references a missing node.

empty and error are distinct states. An empty response is valid; an error may be retried. Closing the popup does not have to destroy the query or cache, but it must reset isOpen and activeId.

Step 3: Separate debounce, cancellation, and result commitment

An input event first updates query synchronously. If composition is active, the normalized query has fewer than two characters, or it is only whitespace, clear the timer, abort the current request, and reset the list. Otherwise start the request after 300 ms. The request flow can be expressed as pseudocode:

async function search(rawQuery) { const query = normalize(rawQuery) const seq = ++latestRequestSeq

controller?.abort() controller = new AbortController() setStatus("loading")

try { const items = await fetchSuggestions(query, controller.signal) if (seq !== latestRequestSeq || query !== normalize(currentQuery)) return commit(items.slice(0, 10)) } catch (error) { if (isAbort(error)) return if (seq === latestRequestSeq && query === normalize(currentQuery)) { commitError(error) } } }

AbortController can stop an unfinished Fetch request and response-body consumption, which saves work. The request sequence is the correctness gate. An old operation may already have completed, or a caller may use a data layer that does not fully honor the signal, so calling abort() alone does not prove that stale results cannot overwrite fresh ones.

With a 300 ms debounce and a 400 ms API p95, the p95 path from the last keypress to results is about 700 ms before rendering. This derivation makes the trade-off explicit. If the experience needs to be faster, use an exact-query cache or lower the debounce after measuring request cost; do not claim that a 300 ms debounce can still produce a 150 ms uncached response.

Step 4: Handle IME, pointer, and focus correctly

compositionstart sets isComposing to true. During composition, input updates visible text but does not schedule a search. compositionend clears the flag and schedules one search for the final committed text. Keyboard events may still occur while composition is active and report isComposing, so Enter must not select a suggestion at that time. Test the framework's event wrapper in target browsers instead of assuming desktop English input represents every sequence.

Mouse and touch selection must account for the input blurring before a click runs, which could remove the clicked option. The primary pointerdown can retain input focus, while a click or a pointerup that stayed within a movement threshold completes selection through one shared path. Scrolling the list must not turn ordinary pointer movement into a selection. An outside click closes the popup, while the selection callback still runs exactly once.

DOM focus remains on the input. Popup options are excluded from the Tab sequence, and Tab follows the browser default to leave the widget. This keeps native text-editing behavior and assistive-technology input modes stable.

Step 5: Implement the combobox and keyboard contract

The input has a visible label, or gets its name through aria-labelledby or aria-label. It uses role="combobox", aria-autocomplete="list", aria-expanded synchronized with the popup, aria-controls pointing to the list, and aria-activedescendant only while an active option exists.

The suggestion container uses role="listbox". Each item has role="option" and a stable DOM ID, and the visually active item also has aria-selected="true". Down Arrow makes the first option active and then moves downward; Up Arrow moves in reverse while DOM focus stays on the input. The base design stops at the boundaries rather than wrapping. Enter accepts the active option. Escape closes the popup but preserves the input text.

Do not unconditionally intercept Left, Right, Home, End, Backspace, or printable characters. An editable combobox should preserve the browser's native single-line editing behavior. Call preventDefault() only when the component actually handles list movement, selection, or dismissal.

The result list does not need to become a high-priority live region on every update. Use a separate role="status" element to announce “Searching,” “10 results,” or “No results” without moving focus. Announcing on every keypress can make the widget excessively chatty.

Step 6: Bound caching and failure recovery

A cache key includes at least the normalized query, locale, filters, and data version. Query-only caching returns the wrong data after a locale or filter change. Use an exact-query cache with both a TTL and a capacity bound. A hit can render immediately, followed by background refresh only if the product's freshness policy calls for it.

Cached responses still pass the current-query and request-sequence checks. Clearing input, selecting a result, unmounting the component, or changing a dependency cancels timers and requests. A server error exposes a small retry action; it does not become a selectable row in the suggestion list. Retry creates a new request sequence, so an old error cannot overwrite a later success.

Highlight matches with text nodes or trusted structured fragments, not unsanitized server markup. Encode query parameters correctly, and do not log full sensitive queries unless they are genuinely required.

Step 7: Verify invariants with adversarial scenarios

Unit tests with a controlled clock prove that 299 ms causes no request, 300 ms causes one request, continued typing cancels the previous timer, and a query shorter than two characters resets state. State tests cover success, empty results, error, retry, close, and selection.

A race test makes the slow response for a arrive after the fast response for ab; the final list may contain only ab results. Test three paths separately: an abortable request, an already completed request, and a data layer that ignores the cancellation signal.

Interaction tests cover arrows, Enter, Escape, Tab, click, touch, outside click, composition, and an active option disappearing after results change. Each step asserts visible state, input value, callback count, DOM focus, and ARIA attributes together.

Automated accessibility scans catch only part of the contract. Use a keyboard and a target screen reader to complete input, loading, result announcement, selection, empty results, and error recovery. Test touch and the software keyboard in supported mobile browsers. Performance tests record the distribution from final keypress to the first interactive result, request cancellation rate, cache hit rate, and discarded stale-response count.

High-Quality Sample Answer

“I would scope this to a frontend, single-select suggestion component; an existing API owns search and ranking. The component accepts a controlled value, query function, stable key, readable label, custom row renderer, and selection callback. Internally, query text, the chosen object, request status, popup state, active option, IME state, and request sequence remain separate, so empty results, errors, and a closed popup do not collapse into one boolean.

The input updates immediately. Once it has two characters and composition has ended, I debounce for 300 milliseconds, cancel the previous request, and start a sequenced request. A response can commit only if its sequence is still latest and its query still matches the input. Cancellation saves work; sequencing guarantees correctness. The 300 plus 400 millisecond assumptions put the uncached p95 path at about 700 milliseconds, so the real latency and request cost should determine the debounce.

Semantically, the named input is a combobox controlling a listbox. DOM focus stays on the input; arrow keys change a stable aria-activedescendant, Enter selects, Escape closes, and Tab leaves normally. Options use option and aria-selected, while a separate status region announces loading, result count, and no results. Searching and Enter selection are both suspended during composition.

The cache is bounded by TTL and capacity and keyed by normalized query, locale, and filters. I would verify slow-old versus fast-new responses, IME, keyboard and screen-reader behavior, pointer blur, empty results, retry, and cache invalidation. Success means every visible result belongs to the current query and the focus and accessibility state match the visual state at every transition.”

Common Mistakes

  • Only debouncing → Debounce reduces requests but does not order responses → Add cancellation and gate commitment on both the latest sequence and current query.
  • Only calling abort() Old work may already be complete or the data layer may ignore the signal → Treat cancellation as an optimization and the sequence check as the correctness condition.
  • Using an array index as the active item → The same index may identify a different result after reordering → Use a stable result ID and confirm it still exists after replacing the list.
  • Moving DOM focus into every option → Input, text editing, and assistive-technology focus can diverge → Keep focus on the input and expose the active item through aria-activedescendant.
  • Intercepting every keyboard event → Native Home, End, arrow, and IME editing behavior breaks → Intercept only keys that the component actually uses for list navigation, selection, or dismissal.
  • Searching each IME update → Uncommitted phonetic text produces bad requests, and Enter may select accidentally → Track the composition session and search only after it ends.
  • Making the whole result list an urgent announcement → Every input update triggers verbose speech → Use a restrained role="status" message for loading, count, and empty results.
  • Caching by query alone → Locale or filter changes can hit incorrect data → Put every result-affecting condition and version in the key, with TTL and capacity limits.

Follow-Ups and How to Handle Them

Follow-up 1: What changes if the list must show 1,000 results?

Challenge the requirement first: autocomplete should usually rank and limit a small set of relevant suggestions. If browsing a large set is required, add windowing. The active option must remain mounted, or be scrolled into the mounted window before updating aria-activedescendant; the attribute cannot reference a virtualized-away node. Expose meaningful position and total-size semantics and verify them with a screen reader, not only a frame-rate measurement.

Follow-up 2: How can multiple pages share requests and cache entries?

Move cache storage and request deduplication into a page-level data layer while the component still consumes only fetchSuggestions. Shared keys include locale, filters, authorization scope, and data version. Calls for the same key may share a Promise, but each component keeps its own request sequence because two inputs can have different current queries and unmount times.

Follow-up 3: How would you add local history and empty-query suggestions?

Mark each source as history or remote, then define deletion, privacy, and cross-device synchronization. History on an empty query is a separate state from completing typed input, so it needs an explicit aria-autocomplete, heading, and announcement policy. History selection still follows the stable-ID contract, and sensitive searches must not be persisted by default.

Follow-up 4: How would this work with server rendering and hydration?

The server can render the label and empty input, while the client owns the interactive popup. Input, list, and option IDs must be stable across server and client; random first-render IDs can break aria-controls after hydration. If the server preloads suggestions, serialize the cache key, query, and data version together and reuse the payload only when all three still match.

Follow-up 5: How would you change the component to support multi-select chips?

That changes focus, deletion, and selected-item semantics; replacing selectedItem with an array is insufficient. Define left and right movement between the input and chips, Backspace confirmation, duplicates, a maximum count, and screen-reader announcements. The suggestion list can remain a combobox, but selected chips need their own navigable region and a fresh keyboard and assistive-technology test pass.

Public sources

Related questions