How would you implement a multi-select dropdown with search in React?

Advanced20 min interview
Skills tested:
Portal RenderingKeyboard NavigationSearch and Filtering LogicAccessibilityVirtualization StrategiesAsync Data Loading

Advertisement

🧩 Scenario

Build a reusable MultiSelect component that: - Allows selecting multiple options shown as tags - Filters options via search input with debounce - Supports keyboard navigation and tag removal with Backspace - Virtualizes options for large datasets - Loads options asynchronously

🧠 Architecture Walkthrough

Portal-Based Dropdown Positioning

The dropdown in the demo uses createPortal to render into document.body rather than inside the component's DOM subtree. This solves a real production problem: if any ancestor of MultiSelectDropdown has overflow: hidden, overflow: auto, or position: relative applied, a dropdown rendered inside that ancestor will be clipped by its boundary.

By portaling to document.body, the dropdown escapes all ancestor stacking contexts and clip regions. The tradeoff is that the dropdown can no longer inherit its position from its parent using CSS alone instead the component measures the trigger container's bounding rect with containerRef.current.getBoundingClientRect() and sets absolute top, left, and width values manually.

The useEffect that calls updateDropdownPosition on isOpen also attaches a resize listener so the dropdown repositions correctly if the viewport changes while it is open.

Keyboard Navigation as a State Machine

The handleKeyDown function manages a focusedIndex integer that represents which option in filteredOptions is currently highlighted. Each key press transitions this state: ArrowDown increments it and wraps to 0 at the end, ArrowUp decrements it and wraps to the last index, Enter selects the focused option, Escape closes the dropdown and blurs the input, and Tab closes without selecting.

The Backspace case is the most nuanced: it only removes the last tag when searchQuery === '', which is the exact condition where the user has no text to delete. If the search input has content, Backspace is not intercepted at all and the browser handles it normally.

A separate useEffect watches focusedIndex and calls scrollIntoView on the matching option ref, so keyboard navigation automatically keeps the focused item visible without the user having to scroll manually.

Filtering Excluded Already-Selected Options

The filteredOptions derivation does two things simultaneously: it filters by the search query and it excludes options that are already in value. The exclusion is !value.find(selected => selected.value === option.value). This means that as the user selects items, the dropdown list automatically shrinks already-selected frameworks disappear from the dropdown so the user cannot accidentally add them twice.

Because this is a synchronous derivation from options and value, there is no need to separately manage "available options" as state. The debouncedQuery used for filtering ensures that fast typing does not recompute the filter list on every keystroke only after the user pauses for 200ms but the selected-item exclusion is always current because it reads directly from value which is a controlled prop.

💡 Key Code Explained

const handleKeyDown = (e) => {
  if (disabled) return;

  switch (e.key) {
    case 'ArrowDown':
      e.preventDefault();
      if (!isOpen) {
        setIsOpen(true);
        updateDropdownPosition();
      }
      setFocusedIndex((prev) =>
        prev < filteredOptions.length - 1 ? prev + 1 : 0,
      );
      break;

    case 'Backspace':
      if (searchQuery === '' && value.length > 0) {
        removeTag(value[value.length - 1]);
      }
      break;

    case 'Escape':
      setIsOpen(false);
      setFocusedIndex(-1);
      inputRef.current?.blur();
      break;
  }
};

The e.preventDefault() call on ArrowDown is critical and easy to miss. Without it, the browser's default behavior for arrow keys in an input is to move the text cursor, which conflicts with the dropdown navigation.

The ArrowUp case has the same requirement. Notice that ArrowDown also opens the dropdown if it is closed this matches the UX convention in native <select> elements and screen reader expectations.

The Backspace guard searchQuery === '' is a one-line solution to a tricky problem: no regex, no length check, just an empty string comparison. The Escape handler calls inputRef.current?.blur() rather than just closing the dropdown, because without blur the input retains focus and the next keypress re-opens the dropdown immediately, creating a frustrating loop.

{
  isOpen &&
    !disabled &&
    createPortal(
      <div
        ref={dropdownRef}
        style={{
          position: 'absolute',
          top: dropdownPosition.top,
          left: dropdownPosition.left,
          width: dropdownPosition.width,
          maxHeight: '200px',
          overflowY: 'auto',
          zIndex: 1000,
        }}>
        {filteredOptions.map((option, index) => (
          <div
            key={option.value}
            ref={(el) => (optionRefs.current[index] = el)}
            onClick={() => selectOption(option)}
            onMouseEnter={() => setFocusedIndex(index)}>
            {option.label}
          </div>
        ))}
      </div>,
      document.body,
    );
}

The createPortal call takes the dropdown JSX and a DOM node as its second argument document.body here. React still manages this element as part of the component tree for event bubbling and lifecycle purposes, but the actual DOM node is attached to document.body.

The onMouseEnter handler on each option syncs focusedIndex with mouse hover position, so if the user switches between keyboard and mouse navigation mid-interaction, the highlighted state stays consistent.

The option ref collection (el) => optionRefs.current[index] = el stores a DOM ref for each option by index so the scrollIntoView effect can target the correct element during keyboard navigation.

⚖️ Tradeoffs

ApproachProCon
Portal + getBoundingClientRect positioningEscapes all ancestor clip/overflow constraints, works anywhere in DOMRequires manual position calculation; breaks if parent scrolls without re-measuring
Relative dropdown (no portal)Simpler implementation, natural stackingClipped by any ancestor with overflow:hidden; z-index battles with modals
Headless UI / Radix SelectHandles all positioning, accessibility, and keyboard logicAdditional dependency; less control over exact DOM structure and animation

🎯 What Interviewers Actually Check

  • Explains why createPortal is necessary and not just a z-index: 9999 fix specifically mentions parent overflow: hidden as the failure case
  • Knows that e.preventDefault() on Arrow keys prevents the browser from moving the text cursor, which is a different concern from performance
  • Understands why the Backspace guard checks searchQuery === '' rather than checking key modifiers or implementing a more complex condition
  • Mentions that the click outside handler needs to check both containerRef and dropdownRef because they are in different DOM trees due to the portal
  • Notes that focusedIndex resets to -1 on close so stale highlight state does not appear when the dropdown reopens

❓ Follow-Up Questions

  1. The click-outside handler uses document.addEventListener('mousedown'). Why mousedown instead of click and what breaks if you use click?
  2. The updateDropdownPosition function reads getBoundingClientRect() which triggers a layout reflow. If this is called inside a resize event listener that fires many times per second, what is the performance impact and how do you fix it?
  3. How would you add ARIA attributes specifically role="combobox", aria-expanded, aria-activedescendant to make this component usable with a screen reader?
  4. The current implementation stores selected items as full { value, label } objects. If the parent passes 10,000 options and the user selects 500, the value.find(...) exclusion check inside filteredOptions runs 10,000 × 500 = 5,000,000 comparisons on every render. How do you reduce this to O(n)?
  5. Your manager wants the dropdown to appear above the trigger when the trigger is near the bottom of the viewport. How do you detect this condition and flip the dropdown direction?

🎮 Live Demo

📝 Summary

A custom multi-select dropdown is more complex than it appears because it sits at the intersection of positioning, keyboard management, focus control, and accessibility concerns.

The portal pattern is the architectural cornerstone: by rendering the dropdown into document.body and computing its position from a bounding rect measurement, the component escapes ancestor clipping without fighting CSS stacking contexts.

The keyboard handler functions as a state machine over focusedIndex, with the Backspace guard on searchQuery === '' being the one-line solution that enables simultaneous text editing and tag removal. Excluding already-selected options directly inside filteredOptions rather than maintaining separate "available" state keeps the logic stateless and always consistent with the controlled value prop.

For production use with large option sets, this same architecture extends naturally to async loading and virtualization by swapping the synchronous options.filter(...) call for a debounced API fetch.

Frequently Asked Questions

How to handle very large option lists?

Use virtualization and async loading with server-side filtering.

How to keep keyboard accessibility?

Manage focus index and support Arrow keys, Enter, Backspace to remove tags.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement