A live search provides real-time suggestions as the user types, but it needs debouncing to avoid spamming the API, proper UX for empty states, and accessible navigation via keyboard.
🧠 Key Concepts
- Debouncing → Delay API call until user stops typing.
- Empty Queries → Avoid API calls when input is empty.
- Keyboard Navigation → Arrow keys + Enter key to select.
- Accessibility → Proper ARIA roles, focus management.
🧾 Step-by-Step Implementation
import React, { useState, useEffect, useRef } from "react";
export default function LiveSearch() {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState([]);
const [activeIndex, setActiveIndex] = useState(-1);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const listRef = useRef(null);
const debounceTimeout = useRef(null);
useEffect(() => {
if (!query.trim()) {
setSuggestions([]);
setOpen(false);
return;
}
setLoading(true);
clearTimeout(debounceTimeout.current);
debounceTimeout.current = setTimeout(fetchSuggestions, 500);
}, [query]);
const fetchSuggestions = async () => {
try {
const res = await fetch(`https://api.datamuse.com/sug?s=${query}`);
const data = await res.json();
setSuggestions(data);
setOpen(true);
} catch (err) {
console.error("Error fetching suggestions", err);
} finally {
setLoading(false);
}
};
const handleKeyDown = (e) => {
if (!open) return;
if (e.key === "ArrowDown") {
setActiveIndex((prev) => (prev + 1) % suggestions.length);
} else if (e.key === "ArrowUp") {
setActiveIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length);
} else if (e.key === "Enter" && activeIndex >= 0) {
selectSuggestion(suggestions[activeIndex].word);
} else if (e.key === "Escape") {
setOpen(false);
}
};
const selectSuggestion = (value) => {
setQuery(value);
setOpen(false);
};
return (
<div style={{ position: "relative", width: "300px" }}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
aria-expanded={open}
aria-owns="suggestion-list"
aria-activedescendant={
activeIndex >= 0 ? `suggestion-${activeIndex}` : undefined
}
placeholder="Search..."
/>
{loading && <p>Loading...</p>}
{open && suggestions.length > 0 && (
<ul
id="suggestion-list"
ref={listRef}
role="listbox"
style={{
position: "absolute",
width: "100%",
border: "1px solid #ccc",
background: "white",
marginTop: "4px",
listStyle: "none",
padding: 0,
maxHeight: "200px",
overflowY: "auto",
zIndex: 1000
}}
>
{suggestions.map((item, index) => (
<li
key={item.word}
id={`suggestion-${index}`}
role="option"
aria-selected={activeIndex === index}
style={{
padding: "8px",
background: activeIndex === index ? "#eee" : "#fff",
cursor: "pointer"
}}
onMouseDown={() => selectSuggestion(item.word)} // onMouseDown prevents blur before select
onMouseEnter={() => setActiveIndex(index)}
>
{item.word}
</li>
))}
</ul>
)}
</div>
);
}
🧭 Why This Works
- Debounced API Calls: Uses
setTimeoutto trigger API only after the user stops typing for 500ms. - Empty Query Handling: Clears suggestions and avoids calling the API if the input is empty.
- Keyboard Navigation:
ArrowUp,ArrowDown,Enter, andEscapeallow full keyboard control. - Accessibility:
aria-expanded,aria-owns, andaria-activedescendantmake it screen-reader friendly.role="listbox"androle="option"define semantics.
- Suggestion Dropdown: Closes automatically on selection or pressing
Escape.
🧪 Pro Tips
- For real apps, use a proper debounce utility like Lodash’s
debounce. - You can make the dropdown more interactive by supporting mouse hover + keyboard combo.
- Add loading skeletons or spinners for better UX.
- Use Focus Trap for better accessibility.