DEV Community

Md Shahjalal
Md Shahjalal

Posted on

πŸ§‘β€πŸ« Tutorial: How to Build a Searchable Dropdown Component in React

In this comprehensive tutorial, you'll learn how to build a fully accessible, debounced, searchable dropdown component in React using hooks, TypeScript, and best practices.

This component is perfect for autocompleting users, products, or any data fetched from an API.

We'll walk through each part of the code step-by-step, explain what it does, and why it’s structured that way.

βœ… Final Features

  • πŸ” Search-as-you-type with debounce
  • ⏱️ Debounced input to avoid excessive API calls
  • 🌐 Async data fetching
  • πŸ“± Accessible (ARIA attributes, keyboard navigation)
  • ⌨️ Keyboard navigation (arrow keys, Enter, Escape)
  • ❌ Click outside to close
  • 🎨 Customizable rendering
  • πŸ›‘ Error handling and loading states
  • πŸ’¬ No results / empty state
  • πŸ“ Controlled minimum query length

πŸ› οΈ Prerequisites

Before starting, ensure you have:

  • Basic knowledge of React and TypeScript
  • A React project set uu
  • Some familiarity with React hooks (useState, useEffect, useCallback, useMemo, useRef)

πŸ“ Step 1: Create the Component File

Create a new file: SearchableDropdown.tsx

src/components/SearchableDropdown.tsx
Enter fullscreen mode Exit fullscreen mode

🧩 Step 2: Import Dependencies

import React, {
  useState,
  useEffect,
  useRef,
  useCallback,
  useMemo,
} from 'react';
Enter fullscreen mode Exit fullscreen mode

We’re using several React hooks:

  • useState: manage component state
  • useEffect: side effects (fetching, event listeners)
  • useRef: access DOM elements
  • useCallback: memoize functions
  • useMemo: memoize expensive computations

πŸ” Step 3: Create the useDebounce Hook

This custom hook delays updates to the search query so we don’t call the API on every keystroke.

const useDebounce = (value: string, delay: number) => {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
};
Enter fullscreen mode Exit fullscreen mode

πŸ” How It Works:

  • Every time value changes, restart a 300ms timer.
  • Only after 300ms without changes will debouncedValue update.
  • Prevents spamming the API during fast typing.

πŸ’‘ Tip: You can reuse this hook anywhere you need debouncing!


πŸ“¦ Step 4: Define the Component Props

We define a generic interface for flexibility.

interface SearchableDropdownProps<T> {
  fetchOptions: (query: string) => Promise<T[]>;
  displayValue: (item: T) => string;
  renderOption?: (item: T) => React.ReactNode;
  placeholder?: string;
  minQueryLength?: number;
}
Enter fullscreen mode Exit fullscreen mode

πŸ”Ž Explanation:

Prop Purpose
fetchOptions Async function to fetch data based on query
displayValue Function to extract display string from item
renderOption (optional) Custom JSX for each dropdown option
placeholder (optional) Input placeholder text
minQueryLength (optional) Minimum characters before search starts

🎯 Step 5: Forward Ref & Generic Typing

We use React.forwardRef so parent components can access the <input> element (e.g., for focusing).

const SearchableDropdown = React.forwardRef<
  HTMLInputElement,
  SearchableDropdownProps<any>
>(
  (
    {
      fetchOptions,
      displayValue,
      renderOption,
      placeholder = 'Search...',
      minQueryLength = 2,
    },
    ref
  ) => {
    // component logic here
  }
);
Enter fullscreen mode Exit fullscreen mode

πŸ”’ Note: We use any temporarily. For full type safety, we’ll improve this later.

🧠 Step 6: Manage Component State

Inside the component, define all necessary state:

const [query, setQuery] = useState('');
const [options, setOptions] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showDropdown, setShowDropdown] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);

const dropdownRef = useRef<HTMLDivElement>(null);
Enter fullscreen mode Exit fullscreen mode
State Purpose
query Current input value
options Fetched results
loading Show loading indicator
error Handle fetch errors
showDropdown Toggle dropdown visibility
focusedIndex Track keyboard-highlighted option
dropdownRef Detect clicks outside

⏳ Step 7: Debounce the Query

Use the custom hook to debounce the search:

const debouncedQuery = useDebounce(query, 300);
Enter fullscreen mode Exit fullscreen mode

Now debouncedQuery only updates 300ms after user stops typing.

🌐 Step 8: Fetch Options on Debounced Query

Use useEffect to trigger API calls when debouncedQuery changes.

useEffect(() => {
  if (debouncedQuery.trim().length < minQueryLength) {
    setOptions([]);
    setShowDropdown(false);
    setFocusedIndex(-1);
    return;
  }

  const fetchData = async () => {
    setLoading(true);
    setError(null);
    try {
      const results = await fetchOptions(debouncedQuery.trim());
      setOptions(results || []);
      setShowDropdown(true);
    } catch (err: any) {
      console.error(err);
      setError(err.message || 'Failed to load options');
      setOptions([]);
    } finally {
      setLoading(false);
      setFocusedIndex(-1);
    }
  };

  fetchData();
}, [debouncedQuery, fetchOptions, minQueryLength]);
Enter fullscreen mode Exit fullscreen mode

βœ… Logic Flow:

  1. If query too short β†’ clear results and hide dropdown
  2. Otherwise β†’ fetch results
  3. Handle success/error
  4. Always reset focused index

⚠️ Dependencies include fetchOptions and minQueryLength to avoid stale closures.

πŸ–±οΈ Step 9: Close Dropdown on Outside Click

Add a click-outside listener:

useEffect(() => {
  const handleClickOutside = (e: MouseEvent) => {
    if (
      dropdownRef.current &&
      !dropdownRef.current.contains(e.target as Node)
    ) {
      setShowDropdown(false);
    }
  };
  document.addEventListener('mousedown', handleClickOutside);
  return () =>
    document.removeEventListener('mousedown', handleClickOutside);
}, []);
Enter fullscreen mode Exit fullscreen mode

This ensures dropdown closes when clicking elsewhere.

βœ… Step 10: Handle Option Selection

Define a stable callback with useCallback:

const handleSelect = useCallback(
  (item: any) => {
    setQuery(displayValue(item));
    setShowDropdown(false);
    setOptions([]);
    setFocusedIndex(-1);
  },
  [displayValue]
);
Enter fullscreen mode Exit fullscreen mode

Sets the input to selected item and hides the list.

⌨️ Step 11: Keyboard Navigation

Handle arrow keys, Enter, and Escape:

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  if (!showDropdown || options.length === 0) return;

  switch (e.key) {
    case 'ArrowDown':
      e.preventDefault();
      setFocusedIndex(prev => (prev + 1) % options.length);
      break;
    case 'ArrowUp':
      e.preventDefault();
      setFocusedIndex(prev => (prev - 1 + options.length) % options.length);
      break;
    case 'Enter':
      e.preventDefault();
      if (focusedIndex >= 0) handleSelect(options[focusedIndex]);
      break;
    case 'Escape':
      setShowDropdown(false);
      break;
  }
};
Enter fullscreen mode Exit fullscreen mode
  • ArrowDown / ArrowUp: Cycle through options
  • Enter: Select highlighted option
  • Escape: Close dropdown

% options.length enables circular navigation.

🧾 Step 12: Render Options Efficiently

Use useMemo to prevent unnecessary re-renders:

const renderedOptions = useMemo(() => {
  if (loading)
    return <li className='p-2 text-sm text-gray-500 italic'>Loading...</li>;
  if (error) return <li className='p-2 text-sm text-red-500'>{error}</li>;
  if (!loading && options.length === 0 && query.length >= minQueryLength)
    return <li className='p-2 text-sm text-gray-500'>No results found</li>;

  return options.map((option, index) => {
    const isSelected = index === focusedIndex;
    return (
      <li
        key={option.id || index}
        role='option'
        aria-selected={isSelected}
        onClick={() => handleSelect(option)}
        onMouseEnter={() => setFocusedIndex(index)}
        className={`p-2 cursor-pointer hover:bg-gray-100 ${
          isSelected ? 'bg-blue-50' : ''
        } ${index < options.length - 1 ? 'border-b border-gray-100' : ''}`}
      >
        {renderOption ? renderOption(option) : displayValue(option)}
        {option.email && (
          <span className='text-gray-500 text-xs'> ({option.email})</span>
        )}
      </li>
    );
  });
}, [
  loading,
  error,
  options,
  focusedIndex,
  query,
  renderOption,
  displayValue,
  handleSelect,
  minQueryLength,
]);
Enter fullscreen mode Exit fullscreen mode

🎨 Notes:

  • Uses ARIA roles for accessibility
  • Highlights hovered/focused item
  • Falls back to index if no id
  • Shows email if available (could be removed or generalized)

πŸ–ΌοΈ Step 13: Render the UI

Finally, return the JSX:

return (
  <div className='w-72 relative' ref={dropdownRef}>
    <input
      ref={ref}
      type='text'
      placeholder={placeholder}
      value={query}
      onChange={e => setQuery(e.target.value)}
      onFocus={() => {
        if (query.length >= minQueryLength && options.length > 0)
          setShowDropdown(true);
      }}
      onKeyDown={handleKeyDown}
      aria-autocomplete='list'
      aria-expanded={showDropdown}
      aria-controls='dropdown-listbox'
      className='w-full p-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400'
    />

    {showDropdown && (
      <ul
        id='dropdown-listbox'
        role='listbox'
        aria-label='Search results'
        className='absolute top-11 left-0 right-0 bg-white border border-gray-300 rounded-md shadow-md max-h-48 overflow-y-auto z-10'
      >
        {renderedOptions}
      </ul>
    )}
  </div>
);
Enter fullscreen mode Exit fullscreen mode

πŸ” Accessibility Highlights:

  • aria-autocomplete="list": indicates suggestions
  • aria-expanded: shows if dropdown is open
  • aria-controls: links input to listbox
  • role="listbox" and option": proper ARIA semantics

πŸ’‘ The dropdown appears below the input (top-11 β‰ˆ input height + padding)

🏷️ Step 14: Set Display Name

Helpful for debugging in React DevTools:

SearchableDropdown.displayName = 'SearchableDropdown';
Enter fullscreen mode Exit fullscreen mode

πŸš€ Step 15: Export the Component

export default SearchableDropdown;
Enter fullscreen mode Exit fullscreen mode

πŸ§ͺ Step 16: Example Usage

Here’s how to use it in a parent component:

import SearchableDropdown from './SearchableDropdown';

// Example data type
interface User {
  id: number;
  name: string;
  email: string;
}

const App = () => {
  const fetchUsers = async (query: string): Promise<User[]> => {
    const res = await fetch(`/api/users?q=${encodeURIComponent(query)}`);
    return res.json();
  };

  const inputRef = useRef<HTMLInputElement>(null);

  return (
    <div className="p-8">
      <h1>Search Users</h1>
      <SearchableDropdown
        ref={inputRef}
        fetchOptions={fetchUsers}
        displayValue={(user) => user.name}
        renderOption={(user) => (
          <div>
            <strong>{user.name}</strong>
            <span className="text-gray-600"> β€” {user.email}</span>
          </div>
        )}
        placeholder="Search users..."
        minQueryLength={2}
      />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

βœ… Bonus: Improve Type Safety (Optional)

Replace any with proper generics:

const SearchableDropdown = React.forwardRef<
  HTMLInputElement,
  SearchableDropdownProps<unknown>
>(({ fetchOptions, displayValue, renderOption, placeholder = 'Search...', minQueryLength = 2 }, ref) => {
  // ... same logic
});
Enter fullscreen mode Exit fullscreen mode

Or better yet, keep T generic:

const SearchableDropdown = React.forwardRef(
  <T extends unknown>(
    props: SearchableDropdownProps<T>,
    ref: React.ForwardedRef<HTMLInputElement>
  ) => { ... }
) as <T>(
  props: SearchableDropdownProps<T> & React.RefAttributes<HTMLInputElement>
) => JSX.Element;
Enter fullscreen mode Exit fullscreen mode

But this requires advanced typing β€” stick with any or unknown unless you need strict typing.

You’ve now built a production-ready searchable dropdown with:

  • Debouncing
  • Async loading
  • Full keyboard + mouse support
  • Error handling
  • Accessibility
  • Reusability

It's modular, clean, and scalable β€” ideal for forms, user searches, product selectors, and more.

Happy coding! πŸ’»βœ¨

Top comments (0)