๐Ÿง‘โ€๐Ÿซ 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 fet…


This content originally appeared on DEV Community and was authored by Md Shahjalal

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

๐Ÿงฉ Step 2: Import Dependencies

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

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;
};

๐Ÿ” 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;
}

๐Ÿ”Ž 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
  }
);

๐Ÿ”’ 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);
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);

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]);

โœ… 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);
}, []);

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]
);

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;
  }
};
  • 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,
]);

๐ŸŽจ 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>
);

๐Ÿ” 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';

๐Ÿš€ Step 15: Export the Component

export default SearchableDropdown;

๐Ÿงช 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>
  );
};

โœ… 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
});

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;

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! ๐Ÿ’ปโœจ


This content originally appeared on DEV Community and was authored by Md Shahjalal


Print Share Comment Cite Upload Translate Updates
APA

Md Shahjalal | Sciencx (2025-08-24T18:21:58+00:00) ๐Ÿง‘โ€๐Ÿซ Tutorial: How to Build a Searchable Dropdown Component in React. Retrieved from https://www.scien.cx/2025/08/24/%f0%9f%a7%91%f0%9f%8f%ab-tutorial-how-to-build-a-searchable-dropdown-component-in-react-4/

MLA
" » ๐Ÿง‘โ€๐Ÿซ Tutorial: How to Build a Searchable Dropdown Component in React." Md Shahjalal | Sciencx - Sunday August 24, 2025, https://www.scien.cx/2025/08/24/%f0%9f%a7%91%f0%9f%8f%ab-tutorial-how-to-build-a-searchable-dropdown-component-in-react-4/
HARVARD
Md Shahjalal | Sciencx Sunday August 24, 2025 » ๐Ÿง‘โ€๐Ÿซ Tutorial: How to Build a Searchable Dropdown Component in React., viewed ,<https://www.scien.cx/2025/08/24/%f0%9f%a7%91%f0%9f%8f%ab-tutorial-how-to-build-a-searchable-dropdown-component-in-react-4/>
VANCOUVER
Md Shahjalal | Sciencx - » ๐Ÿง‘โ€๐Ÿซ Tutorial: How to Build a Searchable Dropdown Component in React. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/08/24/%f0%9f%a7%91%f0%9f%8f%ab-tutorial-how-to-build-a-searchable-dropdown-component-in-react-4/
CHICAGO
" » ๐Ÿง‘โ€๐Ÿซ Tutorial: How to Build a Searchable Dropdown Component in React." Md Shahjalal | Sciencx - Accessed . https://www.scien.cx/2025/08/24/%f0%9f%a7%91%f0%9f%8f%ab-tutorial-how-to-build-a-searchable-dropdown-component-in-react-4/
IEEE
" » ๐Ÿง‘โ€๐Ÿซ Tutorial: How to Build a Searchable Dropdown Component in React." Md Shahjalal | Sciencx [Online]. Available: https://www.scien.cx/2025/08/24/%f0%9f%a7%91%f0%9f%8f%ab-tutorial-how-to-build-a-searchable-dropdown-component-in-react-4/. [Accessed: ]
rf:citation
» ๐Ÿง‘โ€๐Ÿซ Tutorial: How to Build a Searchable Dropdown Component in React | Md Shahjalal | Sciencx | https://www.scien.cx/2025/08/24/%f0%9f%a7%91%f0%9f%8f%ab-tutorial-how-to-build-a-searchable-dropdown-component-in-react-4/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.