Build a reusable component with search functionality
Generic `<Search items={...} getKey filterFn renderItem onSelect />` (or a hook `useSearch`) — controlled input + debounced filter, supports sync or async sources, keyboard nav (Arrow/Enter/Esc), highlighting, loading/empty/error states, and accessible combobox semantics. Decouples the input from how items are sourced and rendered.
A reusable search component is a study in API design: which assumptions are baked in (sync vs async, single vs grouped, etc.) determines reusability.
1. The API to aim for
Either a component or a hook. A hook is often more reusable; a component bundles the UI.
Hook:
const { query, setQuery, results, status } = useSearch({
source: (q) => fetchUsers(q), // sync or async
debounceMs: 250,
minQueryLength: 1,
});Component:
<Search
items={users} // sync array
// OR
source={(q) => fetchUsers(q)} // async
getKey={(u) => u.id}
filterFn={(u, q) => u.name.toLowerCase().includes(q.toLowerCase())}
renderItem={(u, { isActive, queryParts }) => <UserRow {...u} />}
onSelect={(u) => navigate(u)}
placeholder="Search users..."
/>2. The hook implementation
function useSearch({ source, debounceMs = 250, minQueryLength = 1 }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [status, setStatus] = useState("idle");
useEffect(() => {
if (query.trim().length < minQueryLength) {
setResults([]); setStatus("idle"); return;
}
const controller = new AbortController();
const t = setTimeout(async () => {
setStatus("loading");
try {
const result = typeof source === "function"
? await source(query, { signal: controller.signal })
: source.filter((i) => defaultMatch(i, query));
setResults(result);
setStatus("idle");
} catch (e) {
if (e.name !== "AbortError") setStatus("error");
}
}, debounceMs);
return () => { clearTimeout(t); controller.abort(); };
}, [query, source]);
return { query, setQuery, results, status };
}3. Component composition
function Search({ source, items, getKey, filterFn, renderItem, onSelect, ...rest }) {
const { query, setQuery, results, status } = useSearch({ source: source ?? items, ... });
const [active, setActive] = useState(0);
const onKeyDown = (e) => {
if (e.key === "ArrowDown") setActive((i) => Math.min(i + 1, results.length - 1));
if (e.key === "ArrowUp") setActive((i) => Math.max(i - 1, 0));
if (e.key === "Enter") onSelect(results[active]);
if (e.key === "Escape") setQuery("");
};
return (
<div role="combobox" aria-expanded={results.length > 0} aria-haspopup="listbox">
<input value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={onKeyDown} aria-autocomplete="list" />
<ul role="listbox" aria-activedescendant={`item-${active}`}>
{results.map((it, i) => (
<li
key={getKey(it)}
id={`item-${i}`}
role="option"
aria-selected={i === active}
onMouseEnter={() => setActive(i)}
onClick={() => onSelect(it)}
>
{renderItem(it, { isActive: i === active, query })}
</li>
))}
</ul>
</div>
);
}4. Highlighting matches
A small helper splits the item text on the query and wraps matches:
function highlight(text, q) {
if (!q) return text;
const parts = text.split(new RegExp(`(${escapeRegExp(q)})`, "ig"));
return parts.map((p, i) => p.toLowerCase() === q.toLowerCase() ? <mark key={i}>{p}</mark> : p);
}5. States & UX
- Idle / loading / results / empty / error — each has its own UI.
- Empty vs no-results distinguished.
- Clear button focusing the input.
- Async source with an AbortController to kill stale requests.
6. Accessibility — combobox
role="combobox",aria-expanded,aria-controls,aria-autocomplete="list".role="listbox"for results;role="option"per item witharia-selected.aria-activedescendantso focus stays on the input; arrows move the active option.- Announce result count via live region.
7. Reusability discipline
- Don't bake in a filter — accept
filterFnor a fully-formedsource. - Don't bake in styling — accept
renderItemand minimal default styles. - Don't bake in keyboard behavior assumptions beyond standard combobox.
- Keep async and sync sources consistent — async source returning an array, sync source being an array.
Interview framing
"I'd build it as a hook (useSearch) plus a thin component (<Search>). The hook owns debouncing, AbortController for async, and the result/status state. The component layers controlled input + keyboard combobox semantics + render-prop slots for items. Reusability comes from injection: source (sync array or async function), filterFn for sync filtering, renderItem for presentation, onSelect for action. Accessibility is the combobox pattern — role='combobox' + listbox + aria-activedescendant. States are explicit: idle, loading, empty, error."
Follow-up questions
- •Why expose a hook in addition to a component?
- •How do you support both sync and async sources without two APIs?
- •Why use aria-activedescendant instead of actually focusing options?
- •How would you highlight matched substrings safely?
Common mistakes
- •Baking in a filter that doesn't match the caller's needs.
- •Tight-coupling presentation — no renderItem.
- •Forgetting AbortController on async source.
- •No keyboard nav.
- •Hard-coding which field to search.
Performance considerations
- •Debounce caps requests; memoize the filtered list; virtualize for big lists; AbortController frees server work.
Edge cases
- •Empty query.
- •Very long item lists — virtualize.
- •Async source that throws synchronously.
- •Same item appearing twice (key collision).
Real-world examples
- •Algolia InstantSearch, Downshift, cmdk, Linear's command palette.