Advanced Autocomplete Search Component

Master machine coding by creating a production-ready autocomplete search with debouncing, keyboard navigation, virtual scrolling, and real-time suggestions.

Machine Coding Challenge: Advanced Autocomplete Search

Create a production-ready autocomplete search component that demonstrates advanced React patterns, performance optimization, and exceptional user experience.


Problem Statement

Design and implement an autocomplete search component with the following requirements:

✅ Core Features (Must Have)

  • Real-time search suggestions as user types
  • Debounced input to prevent excessive API calls
  • Keyboard navigation (arrow keys, enter, escape)
  • Click to select suggestions
  • Loading states and error handling
  • Responsive design

Advanced Features (Nice to Have)

  • Virtual scrolling for large suggestion lists
  • Search highlighting in suggestions
  • Recent searches with localStorage persistence
  • Multi-select functionality
  • Custom suggestion rendering
  • Accessibility compliance (ARIA labels, screen reader support)

UI/UX Requirements

  • Clean, modern dropdown interface
  • Smooth animations and transitions
  • Focus management and keyboard shortcuts
  • Empty state and no results handling
  • Mobile-friendly touch interactions

🧠 Clarifying Questions & Scope

Functional Requirements

  • Data Source: Mock API or real API integration?
  • Suggestion Limit: How many suggestions to show? (10, 20, 50?)
  • Search Logic: Exact match, fuzzy search, or prefix matching?
  • Multi-select: Should users be able to select multiple items?
  • Recent Searches: Should we persist and show recent searches?

Technical Constraints

  • Time Limit: 2-3 hours for implementation
  • Framework: React with hooks
  • Styling: Tailwind CSS or custom CSS?
  • Performance: Handle 1000+ suggestions efficiently?

Success Criteria

  • Smooth, responsive user experience
  • Proper error handling and edge cases
  • Clean, maintainable code structure
  • Performance optimization for large datasets

🏗️ High-Level Architecture

1. Component Structure

AutocompleteSearch/
├── AutocompleteInput/     # Main input with dropdown
│   ├── SearchInput/       # Text input with debouncing
│   ├── SuggestionsList/   # Dropdown with suggestions
│   │   ├── SuggestionItem/ # Individual suggestion
│   │   └── VirtualList/   # Performance optimization
│   └── RecentSearches/    # Recent searches section
├── SearchHighlight/       # Highlight matching text
├── LoadingSpinner/        # Loading state
└── ErrorBoundary/         # Error handling

2. State Management Strategy

interface Suggestion {
  id: string;
  label: string;
  value: string;
  category?: string;
  icon?: string;
}

interface AutocompleteState {
  query: string;
  suggestions: Suggestion[];
  selectedIndex: number;
  isOpen: boolean;
  isLoading: boolean;
  error: string | null;
  recentSearches: string[];
  selectedItems: Suggestion[];
}

3. Data Flow

User Input → Debounce → API Call → Filter Results → Update Suggestions → Render

Keyboard Navigation ← Focus Management ← Accessibility ← User Experience

🛠️ Step-by-Step Implementation

Step 1: Core Autocomplete Component

// components/AutocompleteSearch.tsx
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useDebounce } from '../hooks/useDebounce';
import { useKeyboardNavigation } from '../hooks/useKeyboardNavigation';
import { SearchInput } from './SearchInput';
import { SuggestionsList } from './SuggestionsList';
import { LoadingSpinner } from './LoadingSpinner';

interface AutocompleteSearchProps {
  fetchSuggestions: (query: string) => Promise<Suggestion[]>;
  onSelect: (suggestion: Suggestion) => void;
  placeholder?: string;
  debounceMs?: number;
  maxSuggestions?: number;
  multiSelect?: boolean;
}

const AutocompleteSearch: React.FC<AutocompleteSearchProps> = ({
  fetchSuggestions,
  onSelect,
  placeholder = "Search...",
  debounceMs = 300,
  maxSuggestions = 10,
  multiSelect = false
}) => {
  const [query, setQuery] = useState('');
  const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
  const [selectedIndex, setSelectedIndex] = useState(-1);
  const [isOpen, setIsOpen] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const inputRef = useRef<HTMLInputElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const debouncedQuery = useDebounce(query, debounceMs);

  // Fetch suggestions when query changes
  useEffect(() => {
    if (!debouncedQuery.trim()) {
      setSuggestions([]);
      setIsOpen(false);
      return;
    }

    const loadSuggestions = async () => {
      setIsLoading(true);
      setError(null);

      try {
        const results = await fetchSuggestions(debouncedQuery);
        setSuggestions(results.slice(0, maxSuggestions));
        setIsOpen(true);
        setSelectedIndex(-1);
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Failed to load suggestions');
        setSuggestions([]);
      } finally {
        setIsLoading(false);
      }
    };

    loadSuggestions();
  }, [debouncedQuery, fetchSuggestions, maxSuggestions]);

  // Keyboard navigation
  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setSelectedIndex(prev => 
          prev < suggestions.length - 1 ? prev + 1 : 0
        );
        break;

      case 'ArrowUp':
        e.preventDefault();
        setSelectedIndex(prev => 
          prev > 0 ? prev - 1 : suggestions.length - 1
        );
        break;

      case 'Enter':
        e.preventDefault();
        if (selectedIndex >= 0 && suggestions[selectedIndex]) {
          handleSelect(suggestions[selectedIndex]);
        }
        break;

      case 'Escape':
        e.preventDefault();
        setIsOpen(false);
        setSelectedIndex(-1);
        inputRef.current?.blur();
        break;
    }
  }, [suggestions, selectedIndex]);

  const handleSelect = useCallback((suggestion: Suggestion) => {
    onSelect(suggestion);
    setQuery(suggestion.label);
    setIsOpen(false);
    setSelectedIndex(-1);
    inputRef.current?.blur();
  }, [onSelect]);

  const handleInputChange = useCallback((value: string) => {
    setQuery(value);
    if (!value.trim()) {
      setIsOpen(false);
      setSuggestions([]);
    }
  }, []);

  const handleInputFocus = useCallback(() => {
    if (suggestions.length > 0) {
      setIsOpen(true);
    }
  }, [suggestions.length]);

  const handleClickOutside = useCallback((e: MouseEvent) => {
    if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
      setIsOpen(false);
      setSelectedIndex(-1);
    }
  }, []);

  useEffect(() => {
    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, [handleClickOutside]);

  return (
    <div ref={containerRef} className="autocomplete-container relative">
      <SearchInput
        ref={inputRef}
        value={query}
        onChange={handleInputChange}
        onKeyDown={handleKeyDown}
        onFocus={handleInputFocus}
        placeholder={placeholder}
        isLoading={isLoading}
        aria-expanded={isOpen}
        aria-autocomplete="list"
        aria-activedescendant={
          selectedIndex >= 0 ? `suggestion-${selectedIndex}` : undefined
        }
      />

      {isOpen && (
        <SuggestionsList
          suggestions={suggestions}
          selectedIndex={selectedIndex}
          onSelect={handleSelect}
          isLoading={isLoading}
          error={error}
          query={query}
        />
      )}
    </div>
  );
};

Step 2: Custom Hooks Implementation

// hooks/useDebounce.ts
import { useState, useEffect } from 'react';

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
}

// hooks/useRecentSearches.ts
import { useState, useEffect } from 'react';

export function useRecentSearches(key: string, maxItems: number = 10) {
  const [recentSearches, setRecentSearches] = useState<string[]>([]);

  useEffect(() => {
    const saved = localStorage.getItem(key);
    if (saved) {
      try {
        setRecentSearches(JSON.parse(saved));
      } catch {
        setRecentSearches([]);
      }
    }
  }, [key]);

  const addSearch = (search: string) => {
    if (!search.trim()) return;

    setRecentSearches(prev => {
      const filtered = prev.filter(item => item !== search);
      const updated = [search, ...filtered].slice(0, maxItems);
      localStorage.setItem(key, JSON.stringify(updated));
      return updated;
    });
  };

  const clearSearches = () => {
    setRecentSearches([]);
    localStorage.removeItem(key);
  };

  return { recentSearches, addSearch, clearSearches };
}

// hooks/useVirtualization.ts
import { useState, useMemo, useCallback } from 'react';

interface UseVirtualizationOptions {
  items: any[];
  itemHeight: number;
  containerHeight: number;
  overscan?: number;
}

export function useVirtualization({
  items,
  itemHeight,
  containerHeight,
  overscan = 5
}: UseVirtualizationOptions) {
  const [scrollTop, setScrollTop] = useState(0);

  const virtualItems = useMemo(() => {
    const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
    const endIndex = Math.min(
      items.length - 1,
      Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
    );

    return items.slice(startIndex, endIndex + 1).map((item, index) => ({
      ...item,
      index: startIndex + index,
      style: {
        position: 'absolute' as const,
        top: (startIndex + index) * itemHeight,
        height: itemHeight,
        width: '100%'
      }
    }));
  }, [items, itemHeight, containerHeight, scrollTop, overscan]);

  const totalHeight = items.length * itemHeight;

  const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
    setScrollTop(e.currentTarget.scrollTop);
  }, []);

  return {
    virtualItems,
    totalHeight,
    handleScroll
  };
}

Step 3: Supporting Components

// components/SearchInput.tsx
import React, { forwardRef } from 'react';
import { SearchIcon, XIcon } from 'lucide-react';

interface SearchInputProps {
  value: string;
  onChange: (value: string) => void;
  onKeyDown: (e: React.KeyboardEvent) => void;
  onFocus: () => void;
  placeholder?: string;
  isLoading?: boolean;
  'aria-expanded'?: boolean;
  'aria-autocomplete'?: string;
  'aria-activedescendant'?: string;
}

export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
  ({ value, onChange, onKeyDown, onFocus, placeholder, isLoading, ...ariaProps }, ref) => {
    return (
      <div className="relative">
        <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
          <SearchIcon className="h-5 w-5 text-gray-400" />
        </div>
        
        <input
          ref={ref}
          type="text"
          value={value}
          onChange={(e) => onChange(e.target.value)}
          onKeyDown={onKeyDown}
          onFocus={onFocus}
          placeholder={placeholder}
          className="block w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
          {...ariaProps}
        />

        {isLoading && (
          <div className="absolute inset-y-0 right-0 pr-3 flex items-center">
            <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600" />
          </div>
        )}

        {value && !isLoading && (
          <button
            onClick={() => onChange('')}
            className="absolute inset-y-0 right-0 pr-3 flex items-center"
          >
            <XIcon className="h-4 w-4 text-gray-400 hover:text-gray-600" />
          </button>
        )}
      </div>
    );
  }
);

// components/SuggestionsList.tsx
import React from 'react';
import { SuggestionItem } from './SuggestionItem';
import { VirtualList } from './VirtualList';

interface SuggestionsListProps {
  suggestions: Suggestion[];
  selectedIndex: number;
  onSelect: (suggestion: Suggestion) => void;
  isLoading: boolean;
  error: string | null;
  query: string;
}

const SuggestionsList: React.FC<SuggestionsListProps> = ({
  suggestions,
  selectedIndex,
  onSelect,
  isLoading,
  error,
  query
}) => {
  if (isLoading) {
    return (
      <div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg">
        <div className="p-4 text-center text-gray-500">
          <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto mb-2" />
          Loading suggestions...
        </div>
      </div>
    );
  }

  if (error) {
    return (
      <div className="absolute z-10 w-full mt-1 bg-white border border-red-300 rounded-lg shadow-lg">
        <div className="p-4 text-center text-red-600">
          <p>Error: {error}</p>
          <button className="mt-2 text-blue-600 hover:text-blue-800">
            Try again
          </button>
        </div>
      </div>
    );
  }

  if (suggestions.length === 0 && query.trim()) {
    return (
      <div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg">
        <div className="p-4 text-center text-gray-500">
          No suggestions found
        </div>
      </div>
    );
  }

  return (
    <div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-hidden">
      <VirtualList
        items={suggestions}
        itemHeight={48}
        containerHeight={240}
        renderItem={(suggestion, index) => (
          <SuggestionItem
            key={suggestion.id}
            suggestion={suggestion}
            isSelected={index === selectedIndex}
            onSelect={onSelect}
            query={query}
          />
        )}
      />
    </div>
  );
};

// components/SuggestionItem.tsx
import React from 'react';
import { SearchHighlight } from './SearchHighlight';

interface SuggestionItemProps {
  suggestion: Suggestion;
  isSelected: boolean;
  onSelect: (suggestion: Suggestion) => void;
  query: string;
}

const SuggestionItem: React.FC<SuggestionItemProps> = ({
  suggestion,
  isSelected,
  onSelect,
  query
}) => {
  return (
    <div
      className={`px-4 py-3 cursor-pointer transition-colors ${
        isSelected 
          ? 'bg-blue-50 text-blue-900' 
          : 'hover:bg-gray-50 text-gray-900'
      }`}
      onClick={() => onSelect(suggestion)}
      role="option"
      aria-selected={isSelected}
    >
      <div className="flex items-center gap-3">
        {suggestion.icon && (
          <div className="w-5 h-5 text-gray-400">
            {suggestion.icon}
          </div>
        )}
        
        <div className="flex-1 min-w-0">
          <div className="font-medium">
            <SearchHighlight text={suggestion.label} query={query} />
          </div>
          
          {suggestion.category && (
            <div className="text-sm text-gray-500">
              {suggestion.category}
            </div>
          )}
        </div>
      </div>
    </div>
  );
};

Step 4: Advanced Features

// components/SearchHighlight.tsx
import React from 'react';

interface SearchHighlightProps {
  text: string;
  query: string;
}

const SearchHighlight: React.FC<SearchHighlightProps> = ({ text, query }) => {
  if (!query.trim()) {
    return <span>{text}</span>;
  }

  const regex = new RegExp(`(${query})`, 'gi');
  const parts = text.split(regex);

  return (
    <span>
      {parts.map((part, index) => 
        regex.test(part) ? (
          <mark key={index} className="bg-yellow-200 px-1 rounded">
            {part}
          </mark>
        ) : (
          <span key={index}>{part}</span>
        )
      )}
    </span>
  );
};

// components/VirtualList.tsx
import React, { useRef } from 'react';
import { useVirtualization } from '../hooks/useVirtualization';

interface VirtualListProps {
  items: any[];
  itemHeight: number;
  containerHeight: number;
  renderItem: (item: any, index: number) => React.ReactNode;
}

const VirtualList: React.FC<VirtualListProps> = ({
  items,
  itemHeight,
  containerHeight,
  renderItem
}) => {
  const containerRef = useRef<HTMLDivElement>(null);
  const { virtualItems, totalHeight, handleScroll } = useVirtualization({
    items,
    itemHeight,
    containerHeight,
    overscan: 10
  });

  return (
    <div
      ref={containerRef}
      className="overflow-auto"
      style={{ height: containerHeight }}
      onScroll={handleScroll}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        {virtualItems.map(({ index, style, ...item }) => (
          <div key={item.id || index} style={style}>
            {renderItem(item, index)}
          </div>
        ))}
      </div>
    </div>
  );
};

UI/UX Enhancements

Responsive Design

/* Tailwind classes for responsive autocomplete */
.autocomplete-container {
  @apply relative w-full max-w-md mx-auto;
}

@media (max-width: 640px) {
  .autocomplete-container {
    @apply max-w-full;
  }
  
  .suggestions-list {
    @apply max-h-48;
  }
}

Accessibility Features

// Enhanced accessibility with ARIA labels
const AutocompleteSearch: React.FC<AutocompleteSearchProps> = (props) => {
  return (
    <div
      className="autocomplete-container"
      role="combobox"
      aria-expanded={isOpen}
      aria-haspopup="listbox"
      aria-owns="suggestions-list"
    >
      <SearchInput
        aria-autocomplete="list"
        aria-activedescendant={
          selectedIndex >= 0 ? `suggestion-${selectedIndex}` : undefined
        }
        aria-controls="suggestions-list"
        {...inputProps}
      />
      
      {isOpen && (
        <ul
          id="suggestions-list"
          role="listbox"
          aria-label="Search suggestions"
          className="suggestions-list"
        >
          {suggestions.map((suggestion, index) => (
            <li
              key={suggestion.id}
              id={`suggestion-${index}`}
              role="option"
              aria-selected={index === selectedIndex}
            >
              <SuggestionItem
                suggestion={suggestion}
                isSelected={index === selectedIndex}
                onSelect={handleSelect}
                query={query}
              />
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

Performance Optimizations

Memoization and Optimization

// Optimized suggestion rendering with React.memo
const SuggestionItem = React.memo<SuggestionItemProps>(({
  suggestion,
  isSelected,
  onSelect,
  query
}) => {
  const handleClick = useCallback(() => {
    onSelect(suggestion);
  }, [onSelect, suggestion]);

  return (
    <div
      className={`suggestion-item ${isSelected ? 'selected' : ''}`}
      onClick={handleClick}
    >
      <SearchHighlight text={suggestion.label} query={query} />
    </div>
  );
});

// Optimized search with useMemo
const filteredSuggestions = useMemo(() => {
  if (!query.trim()) return suggestions;
  
  return suggestions.filter(suggestion =>
    suggestion.label.toLowerCase().includes(query.toLowerCase()) ||
    suggestion.category?.toLowerCase().includes(query.toLowerCase())
  );
}, [suggestions, query]);

Caching and Preloading

// hooks/useSearchCache.ts
import { useRef, useCallback } from 'react';

export function useSearchCache() {
  const cache = useRef<Map<string, Suggestion[]>>(new Map());

  const get = useCallback((key: string) => {
    return cache.current.get(key);
  }, []);

  const set = useCallback((key: string, suggestions: Suggestion[]) => {
    cache.current.set(key, suggestions);
  }, []);

  const clear = useCallback(() => {
    cache.current.clear();
  }, []);

  return { get, set, clear };
}

// Enhanced autocomplete with caching
const AutocompleteSearch: React.FC<AutocompleteSearchProps> = (props) => {
  const { get, set } = useSearchCache();

  const loadSuggestions = useCallback(async (query: string) => {
    // Check cache first
    const cached = get(query);
    if (cached) {
      setSuggestions(cached);
      return;
    }

    // Fetch from API
    const results = await props.fetchSuggestions(query);
    set(query, results); // Cache results
    setSuggestions(results);
  }, [get, set, props.fetchSuggestions]);
};

Key Takeaways

What You've Learned

  1. Performance Optimization: Debouncing, virtualization, and caching
  2. User Experience: Keyboard navigation and accessibility
  3. State Management: Complex search states and error handling
  4. Component Architecture: Modular and reusable design
  5. Real-world Patterns: Production-ready implementation

Common Pitfalls to Avoid

  • No Debouncing: Excessive API calls on every keystroke
  • Poor Accessibility: Missing keyboard navigation and ARIA labels
  • Memory Leaks: Not cleaning up event listeners and timers
  • Bad UX: No loading states or error handling
  • Performance Issues: Rendering all suggestions without virtualization

Next Steps

  • Implement advanced search algorithms (fuzzy search, typo tolerance)
  • Add search analytics and user behavior tracking
  • Integrate with real search APIs (Algolia, Elasticsearch)
  • Add voice search capabilities
  • Implement search suggestions and autocomplete

This autocomplete search component demonstrates advanced React patterns and real-world considerations. The skills you've learned here are essential for building responsive, user-friendly search experiences that scale with your application!