Back to Machine Coding
Machine Coding
easy
mid

How would you build a reusable component that includes 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.

5 min read·~35 min to think through

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:

js
const { query, setQuery, results, status } = useSearch({
  source: (q) => fetchUsers(q),     // sync or async
  debounceMs: 250,
  minQueryLength: 1,
});

Component:

jsx
<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

js
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

jsx
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:

js
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 with aria-selected.
  • aria-activedescendant so 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 filterFn or a fully-formed source.
  • Don't bake in styling — accept renderItem and 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.

Senior engineer discussion

Seniors separate concerns: hook owns state, component owns interaction shell, callers own filtering and rendering. They build the combobox ARIA shell properly and don't conflate 'reusable' with 'configurable' — the right API has fewer knobs, not more.

Related questions