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:
- If query too short → clear results and hide dropdown
- Otherwise → fetch results
- Handle success/error
- Always reset focused index
⚠️ Dependencies include
fetchOptions
andminQueryLength
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 noid
- 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"
andoption"
: 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

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-6/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.