Questions

Designing an Autocomplete System

Problem Statement

Design a reusable and scalable autocomplete component that provides real-time search suggestions as users type. The component should handle various data sources, support customization, and ensure optimal performance and accessibility.

Key Requirements:

  • Real-time suggestions as users type
  • Multiple data sources (API, local data, or both)
  • Keyboard navigation and mouse interactions
  • Performance optimization with debouncing and caching
  • Accessibility compliance (WCAG 2.1)
  • Customizable styling and behavior
  • Error handling and loading states

Clarifying Questions

Before diving into the design, consider the following:

Functional Requirements

  • Data Source: Will suggestions come from a remote API, local static data, or both?
  • Result Types: Are suggestions limited to text, or do they include images, links, or other formats?
  • User Interaction: Should the component support keyboard navigation, mouse interactions, or both?
  • Performance: What are the expectations regarding response time and scalability?
  • Accessibility: What accessibility standards need to be met?
  • Internationalization: Is support for multiple languages required?
  • Offline Support: Should the component function without an internet connection?

Technical Constraints

  • Browser Support: What browsers and versions need to be supported?
  • Bundle Size: Are there size constraints for the component?
  • Framework: Is this for React, Vue, vanilla JS, or framework-agnostic?
  • Mobile Support: Does it need to work on touch devices?

High-Level Architecture

1. Input Handler

  • Captures user input with proper event handling
  • Implements debouncing to limit the frequency of API calls
  • Triggers suggestion fetching based on input changes
  • Handles input validation and sanitization

2. Suggestion Fetcher

  • Retrieves suggestions from the specified data source
  • Supports both synchronous (local data) and asynchronous (API) fetching
  • Handles errors and loading states gracefully
  • Implements retry logic for failed requests

3. Cache Layer

  • Stores recent queries and their results to reduce redundant fetches
  • Implements strategies like Least Recently Used (LRU) eviction
  • Enhances performance, especially for repeated queries
  • Supports cache invalidation and expiration

4. Suggestion Renderer

  • Displays the list of suggestions with proper virtualization
  • Highlights matching substrings for better UX
  • Supports customization for different result types (e.g., text, images)
  • Handles empty states and loading indicators

5. Interaction Handler

  • Manages keyboard and mouse interactions
  • Allows navigation through suggestions using arrow keys
  • Handles selection via Enter key or mouse click
  • Manages focus states and accessibility

Data Handling Strategies

Debouncing

Implement a debounce mechanism to delay the fetch operation until the user stops typing for a specified duration (typically 300-500ms). This reduces unnecessary API calls and improves performance.

const useDebounce = (value: string, delay: number) => {
  const [debouncedValue, setDebouncedValue] = useState(value);
  
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

Caching

Use a caching strategy to store previous query results. A simple Map or LRU cache can be used, where the query string is the key, and the suggestions are the value.

class AutocompleteCache {
  private cache = new Map<string, Suggestion[]>();
  private maxSize = 100;
  
  get(query: string): Suggestion[] | undefined {
    return this.cache.get(query);
  }
  
  set(query: string, suggestions: Suggestion[]): void {
    if (this.cache.size >= this.maxSize) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(query, suggestions);
  }
}

For local data sources, implement fuzzy search algorithms (e.g., Levenshtein distance, Fuse.js) to provide suggestions even when the user's input has minor typos or variations.


Customization and Flexibility

Styling Options

  • Theme Support: Allow customization of colors, fonts, and spacing
  • Responsive Design: Adapt to different screen sizes
  • Dark Mode: Support for light and dark themes
  • Custom CSS Classes: Allow external styling

Configuration Options

  • Debounce Delay: Configurable delay for API calls
  • Minimum Input Length: Minimum characters before showing suggestions
  • Maximum Suggestions: Limit the number of displayed suggestions
  • Highlighting: Customize how matching text is highlighted

Result Templates

Support custom templates for rendering different types of suggestions:

  • Simple text suggestions
  • Suggestions with images and descriptions
  • Structured data with multiple fields

Accessibility and Internationalization

Accessibility (WCAG 2.1 Compliance)

  • Keyboard Navigation: Full keyboard support with proper focus management
  • ARIA Attributes: Use appropriate ARIA roles and attributes
  • Screen Reader Support: Ensure compatibility with assistive technologies
  • Focus Management: Proper focus handling during interactions
// Example ARIA attributes
<div
  role="combobox"
  aria-expanded={isOpen}
  aria-haspopup="listbox"
  aria-controls="suggestions-list"
  aria-activedescendant={activeSuggestionId}
>
  <input
    aria-autocomplete="list"
    aria-controls="suggestions-list"
  />
  <ul
    id="suggestions-list"
    role="listbox"
  >
    {suggestions.map((suggestion, index) => (
      <li
        key={suggestion.id}
        role="option"
        aria-selected={index === activeIndex}
        id={`suggestion-${index}`}
      >
        {suggestion.text}
      </li>
    ))}
  </ul>
</div>

Internationalization

  • Multiple Languages: Support for different languages and locales
  • RTL Support: Right-to-left text direction support
  • Localized Placeholders: Customizable placeholder text
  • Number Formatting: Proper number and date formatting

Performance and Scalability

Optimization Techniques

  • Virtualization: For large suggestion lists, use virtual scrolling
  • Lazy Loading: Load suggestions as needed
  • Pagination: For large datasets, implement pagination or infinite scrolling
  • Throttling: Limit the rate of API calls to prevent server overload

Memory Management

  • Cache Size Limits: Prevent memory leaks with cache size limits
  • Cleanup: Proper cleanup of event listeners and timers
  • Garbage Collection: Optimize for garbage collection

Complete Implementation

import React, { useState, useEffect, useRef, useCallback } from "react";

interface Suggestion {
  id: string;
  text: string;
  value: any;
}

interface AutocompleteProps {
  data?: Suggestion[];
  onSearch?: (query: string) => Promise<Suggestion[]>;
  debounceDelay?: number;
  minLength?: number;
  maxSuggestions?: number;
  placeholder?: string;
  onSelect?: (suggestion: Suggestion) => void;
  renderSuggestion?: (suggestion: Suggestion, isHighlighted: boolean) => React.ReactNode;
}

const LOCAL_DATA: Suggestion[] = [
  { id: "1", text: "Apple", value: "apple" },
  { id: "2", text: "Banana", value: "banana" },
  { id: "3", text: "Blueberry", value: "blueberry" },
  { id: "4", text: "Cherry", value: "cherry" },
  { id: "5", text: "Date", value: "date" },
  { id: "6", text: "Durian", value: "durian" },
  { id: "7", text: "Grape", value: "grape" },
  { id: "8", text: "Kiwi", value: "kiwi" },
  { id: "9", text: "Lemon", value: "lemon" },
  { id: "10", text: "Mango", value: "mango" },
];

// Simple substring match for demo (replace with fuzzy search if needed)
function filterSuggestions(query: string, data: Suggestion[]): Suggestion[] {
  if (!query) return [];
  const lower = query.toLowerCase();
  return data.filter((item) => 
    item.text.toLowerCase().includes(lower)
  );
}

// Debounce hook
function useDebounce(value: string, delay: number): string {
  const [debouncedValue, setDebouncedValue] = useState(value);
  
  useEffect(() => {
    const handler = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(handler);
  }, [value, delay]);
  
  return debouncedValue;
}

export default function Autocomplete({
  data = LOCAL_DATA,
  onSearch,
  debounceDelay = 300,
  minLength = 1,
  maxSuggestions = 10,
  placeholder = "Search...",
  onSelect,
  renderSuggestion,
}: AutocompleteProps) {
  const [input, setInput] = useState("");
  const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
  const [highlightIndex, setHighlightIndex] = useState(-1);
  const [showSuggestions, setShowSuggestions] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // Cache: Map query -> suggestions
  const cacheRef = useRef(new Map<string, Suggestion[]>());
  const debouncedInput = useDebounce(input, debounceDelay);

  // Refs for keyboard navigation & accessibility
  const inputRef = useRef<HTMLInputElement>(null);
  const suggestionsRef = useRef<HTMLUListElement>(null);

  // Fetch suggestions (local + cache)
  useEffect(() => {
    const query = debouncedInput.trim();
    
    if (query.length < minLength) {
      setSuggestions([]);
      setShowSuggestions(false);
      return;
    }

    // Check cache first
    if (cacheRef.current.has(query)) {
      setSuggestions(cacheRef.current.get(query)!);
      setShowSuggestions(true);
      return;
    }

    const fetchSuggestions = async () => {
      setIsLoading(true);
      setError(null);
      
      try {
        let results: Suggestion[];
        
        if (onSearch) {
          // Use provided search function
          results = await onSearch(query);
        } else {
          // Use local data with filtering
          results = filterSuggestions(query, data);
        }
        
        // Limit results
        results = results.slice(0, maxSuggestions);
        
        // Cache results
        cacheRef.current.set(query, results);
        setSuggestions(results);
        setShowSuggestions(true);
      } catch (err) {
        setError('Failed to fetch suggestions');
        setSuggestions([]);
      } finally {
        setIsLoading(false);
      }
    };

    fetchSuggestions();
  }, [debouncedInput, data, maxSuggestions, minLength, onSearch]);

  // Handle keyboard events
  const onKeyDown = useCallback((e: React.KeyboardEvent) => {
    if (!showSuggestions) return;

    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        setHighlightIndex((prev) =>
          prev < suggestions.length - 1 ? prev + 1 : 0
        );
        break;
        
      case "ArrowUp":
        e.preventDefault();
        setHighlightIndex((prev) =>
          prev > 0 ? prev - 1 : suggestions.length - 1
        );
        break;
        
      case "Enter":
        e.preventDefault();
        if (highlightIndex >= 0 && highlightIndex < suggestions.length) {
          selectSuggestion(suggestions[highlightIndex]);
        }
        break;
        
      case "Escape":
        setShowSuggestions(false);
        setHighlightIndex(-1);
        break;
    }
  }, [showSuggestions, suggestions, highlightIndex]);

  // Select suggestion
  const selectSuggestion = useCallback((suggestion: Suggestion) => {
    setInput(suggestion.text);
    setShowSuggestions(false);
    setHighlightIndex(-1);
    if (onSelect) onSelect(suggestion);
  }, [onSelect]);

  // Handle click outside to close suggestions
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (
        suggestionsRef.current &&
        !suggestionsRef.current.contains(event.target as Node) &&
        inputRef.current &&
        !inputRef.current.contains(event.target as Node)
      ) {
        setShowSuggestions(false);
        setHighlightIndex(-1);
      }
    };
    
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, []);

  // Highlight matching substring
  const highlightText = (text: string, query: string) => {
    if (!query) return text;
    
    const lowerText = text.toLowerCase();
    const lowerQuery = query.toLowerCase();
    const startIndex = lowerText.indexOf(lowerQuery);
    
    if (startIndex === -1) return text;
    
    const beforeStr = text.substring(0, startIndex);
    const matchStr = text.substring(startIndex, startIndex + query.length);
    const afterStr = text.substring(startIndex + query.length);
    
    return (
      <>
        {beforeStr}
        <strong>{matchStr}</strong>
        {afterStr}
      </>
    );
  };

  return (
    <div className="autocomplete-container" style={{ maxWidth: 300, margin: "auto", position: "relative" }}>
      <input
        type="text"
        ref={inputRef}
        aria-autocomplete="list"
        aria-controls="autocomplete-list"
        aria-activedescendant={
          highlightIndex >= 0 ? `suggestion-${highlightIndex}` : undefined
        }
        aria-expanded={showSuggestions}
        aria-haspopup="listbox"
        role="combobox"
        placeholder={placeholder}
        value={input}
        onChange={(e) => {
          setInput(e.target.value);
          setHighlightIndex(-1);
        }}
        onKeyDown={onKeyDown}
        autoComplete="off"
        style={{
          width: "100%",
          padding: "8px 12px",
          fontSize: 16,
          border: "1px solid #ccc",
          borderRadius: 4,
          boxSizing: "border-box",
        }}
      />
      
      {showSuggestions && (
        <ul
          id="autocomplete-list"
          role="listbox"
          ref={suggestionsRef}
          style={{
            position: "absolute",
            top: "100%",
            left: 0,
            right: 0,
            background: "white",
            border: "1px solid #ccc",
            borderTop: "none",
            margin: 0,
            padding: 0,
            listStyle: "none",
            maxHeight: 200,
            overflowY: "auto",
            zIndex: 1000,
            borderRadius: "0 0 4px 4px",
          }}
        >
          {isLoading && (
            <li style={{ padding: "8px 12px", color: "#666" }}>
              Loading...
            </li>
          )}
          
          {error && (
            <li style={{ padding: "8px 12px", color: "#d32f2f" }}>
              {error}
            </li>
          )}
          
          {!isLoading && !error && suggestions.length === 0 && (
            <li style={{ padding: "8px 12px", color: "#666" }}>
              No suggestions found
            </li>
          )}
          
          {!isLoading && !error && suggestions.map((suggestion, i) => {
            const isHighlighted = i === highlightIndex;
            
            return (
              <li
                key={suggestion.id}
                id={`suggestion-${i}`}
                role="option"
                aria-selected={isHighlighted}
                onMouseDown={(e) => {
                  // Prevent blur on input
                  e.preventDefault();
                  selectSuggestion(suggestion);
                }}
                onMouseEnter={() => setHighlightIndex(i)}
                style={{
                  padding: "8px 12px",
                  backgroundColor: isHighlighted ? "#007acc" : "white",
                  color: isHighlighted ? "white" : "black",
                  cursor: "pointer",
                }}
              >
                {renderSuggestion ? (
                  renderSuggestion(suggestion, isHighlighted)
                ) : (
                  highlightText(suggestion.text, input)
                )}
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
}

Best Practices

Performance

  • Debounce API calls to reduce server load
  • Implement caching for frequently searched terms
  • Use virtualization for large suggestion lists
  • Optimize bundle size with code splitting

User Experience

  • Provide loading states for better feedback
  • Handle errors gracefully with fallback options
  • Support keyboard navigation for accessibility
  • Highlight matching text for clarity

Code Quality

  • Write comprehensive tests for all interactions
  • Use TypeScript for better type safety
  • Follow accessibility guidelines (WCAG 2.1)
  • Document the API clearly

Security

  • Sanitize user input to prevent XSS
  • Validate API responses before rendering
  • Use HTTPS for API calls
  • Implement rate limiting on the server

Interview Tips

What Interviewers Look For:

  1. Understanding of performance implications (debouncing, caching)
  2. Accessibility considerations (ARIA, keyboard navigation)
  3. Error handling strategies (network failures, empty states)
  4. Scalability thinking (large datasets, concurrent users)

Sample Questions:

  • "How would you handle millions of suggestions?"
  • "What if the API is slow or unreliable?"
  • "How would you implement fuzzy search?"
  • "How would you make this work offline?"

Key Points to Mention:

  • Start with simple substring matching, then optimize
  • Consider edge cases (empty results, loading states, errors)
  • Think about accessibility from the beginning
  • Plan for scalability (caching, virtualization, pagination)

Next: Google Sheets Design - Learn how to build a spreadsheet application with formulas and real-time collaboration.