← Back to question bank
UI ComponentMidMedium#3003 · 45m

Build a combobox autocomplete

Design the behavior contract for a combobox autocomplete. Focus on state, keyboard interaction, empty/loading/error states, and how the component composes with product data.

Answer Strategy

Combobox autocomplete is the UI question that exposes whether you build components as interaction systems or as visual boxes. The hard part is not styling the dropdown; it is splitting four owners — input, listbox, query layer, and product API — and proving each owner with the keyboard.

Match the ARIA 1.2 combobox pattern explicitly. The input has role=combobox, aria-expanded that reflects listbox visibility, aria-controls pointing at the listbox, aria-autocomplete describing the suggestion model, and aria-activedescendant naming the virtually focused option. DOM focus stays in the input the whole time so screen readers announce both the typed value and the active option.

Volunteer the production failures: out-of-order responses overwriting the latest query, mousedown vs blur ordering that causes accidental dismissal, missing empty/loading status announcements, IME composition events that mis-fire keyboard handlers, and uncontrolled state that breaks when the parent wants to programmatically clear the value. The reference component leaves the network and debouncing to the parent because product code already owns those concerns.

Reference Implementation: Headless Combobox With ARIA 1.2 Pattern

A controlled combobox that exposes value, query, options, and loading as props. The parent owns network, debouncing, and stale-response protection.

type Option = { id: string; label: string; description?: string };

type ComboboxProps = {
  label: string;
  options: Option[];
  value: string | null;
  onChange: (id: string | null) => void;
  query: string;
  onQueryChange: (next: string) => void;
  loading?: boolean;
};

export function Combobox({
  label,
  options,
  value,
  onChange,
  query,
  onQueryChange,
  loading,
}: ComboboxProps) {
  const [open, setOpen] = React.useState(false);
  const [activeIndex, setActiveIndex] = React.useState<number>(-1);
  const listboxId = React.useId();
  const inputId = React.useId();

  function commit(index: number) {
    const option = options[index];
    if (!option) return;
    onChange(option.id);
    onQueryChange(option.label);
    setOpen(false);
    setActiveIndex(-1);
  }

  function move(delta: number) {
    if (options.length === 0) return;
    setActiveIndex((current) => {
      const next = current < 0 ? (delta > 0 ? 0 : options.length - 1) : current + delta;
      return Math.max(0, Math.min(options.length - 1, next));
    });
  }

  return (
    <div className="combobox">
      <label htmlFor={inputId}>{label}</label>
      <input
        id={inputId}
        role="combobox"
        // ARIA 1.2 combobox pattern: aria-expanded reflects listbox visibility,
        // aria-controls points at the listbox, aria-activedescendant names the
        // virtually focused option without moving DOM focus.
        aria-expanded={open}
        aria-controls={listboxId}
        aria-autocomplete="list"
        aria-activedescendant={
          activeIndex >= 0 ? listboxId + '-option-' + activeIndex : undefined
        }
        value={query}
        onChange={(event) => {
          onQueryChange(event.target.value);
          setOpen(true);
          setActiveIndex(-1);
        }}
        onFocus={() => setOpen(true)}
        onBlur={() => setOpen(false)}
        onKeyDown={(event) => {
          if (event.key === 'ArrowDown') {
            event.preventDefault();
            setOpen(true);
            move(1);
          } else if (event.key === 'ArrowUp') {
            event.preventDefault();
            move(-1);
          } else if (event.key === 'Enter' && activeIndex >= 0) {
            event.preventDefault();
            commit(activeIndex);
          } else if (event.key === 'Escape') {
            event.preventDefault();
            setOpen(false);
            setActiveIndex(-1);
          }
        }}
      />

      {open && (
        <ul id={listboxId} role="listbox" aria-label={label}>
          {loading && <li role="status" className="status">Loading...</li>}
          {!loading && options.length === 0 && <li role="status" className="status">No matches</li>}
          {options.map((option, index) => (
            <li
              key={option.id}
              id={listboxId + '-option-' + index}
              role="option"
              aria-selected={value= option.id || activeIndex= index}
              onMouseDown={(event)=> event.preventDefault()}
              onClick={()=> commit(index)}
            >
              <strong>{option.label}</strong>
              {option.description && <span>{option.description}</span>}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Executable UI Sandbox

UI interview practice should behave like component documentation, not a static snippet. This uses the same isolation pattern as Storybook, Sandpack, CodeSandbox, and StackBlitz: editable source on one side, a sandboxed browser preview on the other. Edit the DOM code, run it, and verify focus, keyboard, pointer, and state behavior in the preview.

Browser sandbox
HTML, CSS, DOM events, focus, and keyboard behavior
Preview running...
Loading editor...

Testing Strategy

Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.

Contract
Assert role=combobox, aria-expanded, aria-controls, aria-autocomplete, and aria-activedescendant transitions during typing and navigation.
Interaction
Drive ArrowDown/ArrowUp/Enter/Escape and a click. Confirm DOM focus stays on the input throughout. Test mousedown vs blur to confirm clicks commit selection without dismissing first.
A11y
Verify accessible name, that loading and empty states are exposed with role=status, and that screen readers announce the active option.
Browser
Run a Playwright smoke path for portal/scroll-lock behavior since combobox bugs frequently pass jsdom but fail in a real browser.
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

describe('Combobox', () => {
  const options = [
    { id: 'a', label: 'Apple' },
    { id: 'b', label: 'Banana' },
  ];

  it('commits selection via keyboard without losing focus', async () => {
    const user = userEvent.setup();
    const onChange = vi.fn();
    const onQueryChange = vi.fn();

    render(
      <Combobox
        label="Fruit"
        options={options}
        value={null}
        onChange={onChange}
        query=""
        onQueryChange={onQueryChange}
      />
    );

    const input = screen.getByRole('combobox', { name: /fruit/i });
    await user.click(input);
    await user.keyboard('{ArrowDown}{Enter}');
    expect(onChange).toHaveBeenCalledWith('a');
    expect(input).toHaveFocus();
  });

  it('reports no-match state with role=status', () => {
    render(
      <Combobox
        label="Fruit"
        options={[]}
        value={null}
        onChange={()=> {}}
        query="zz"
        onQueryChange={()=> {}}
      />
    );
    expect(screen.getByRole('combobox')).toBeInTheDocument();
  });
});

Interviewer Signal

Shows whether you can build components as interaction systems rather than visual boxes.

Constraints

  • Name the controlled and uncontrolled state.
  • Define keyboard and focus behavior.
  • Include loading, empty, disabled, and error states.

Model Answer Shape

  • Start with the accessibility role and interaction contract.
  • Separate rendering slots from state management.
  • Expose callbacks that describe user intent, not internal implementation details.

Tradeoffs

  • A headless primitive is reusable but slower to consume.
  • A product-specific component ships faster but can trap behavior in one use case.

Edge Cases

  • Focus after close, selection, deletion, or route change.
  • Large datasets and slow network responses.
  • Screen reader labels and live updates.

Testing And Proof

  • Keyboard path through the primary workflow.
  • A11y names, descriptions, and roles.
  • State transition after slow or failed data load.

Follow-Ups

  • How would this component be documented in a design system?
  • What props would you refuse to expose?