Build a typeahead search box
- Frontend
- 60 min
- medium
- React · TypeScript
The brief — 60 minutes
We have a search endpoint and a text input. Wire them together the way a product would ship it: results appear as the user types, the list is usable from the keyboard, and a slow response can never overwrite a newer one. Start from an empty component and talk through the trade-offs as you go.
Requirements
- Results update as the user types, hitting
GET /search?q=— no submit button. - Do not fire a request per keystroke; the interviewer will ask what you chose and why.
- A slow response for an older query must never replace results for a newer one.
- The list is navigable with
↑↓, chosen withEnter, dismissed withEscape. - Loading, empty and error states are all visible — no silent failures.
- Screen readers announce the result count as it changes.
Stage 1 of 5 · ~5 min
Clarify before you type
Turn a vague prompt into written assumptions
Nobody hires the candidate who starts typing at second zero. Two minutes of questions is the cheapest part of the hour, and it is the part that decides whether the rest is right.
Ask these, in this order:
- How large is the result set? Ten rows and a thousand are different components.
- Is the endpoint debounced or rate-limited server-side? It changes what your client has to protect against.
- Does selecting a result navigate, or fill the input? This decides whether you need controlled selection state at all.
- Do we own the markup, or is this dropping into a design system? Decides whether you hand-roll the combobox or wire one up.
For the rest of this walkthrough: a few hundred rows at most, no server-side throttling, selecting fills the input, and we own the markup.
Stage 2 of 5 · ~10 min
Name the states, then the transport
A discriminated union and a dumb fetch wrapper
Before the fetch, name the states. Almost every bug in this problem is a state you didn't give a name to — "loading but also showing stale results" being the classic.
type Result = { id: string; label: string };
type SearchState =
| { status: "idle" }
| { status: "loading"; query: string; previous: Result[] }
| { status: "ready"; query: string; results: Result[] }
| { status: "error"; query: string; message: string };The previous field on loading is the whole trick behind a list that doesn't flash empty
between keystrokes: you keep rendering the last good results, dimmed, while the new request
is in flight.
Keep the transport dumb and separate — it makes the component testable, and it is the seam an interviewer will poke at:
export async function search(
query: string,
signal: AbortSignal,
): Promise<Result[]> {
const response = await fetch(`/search?q=${encodeURIComponent(query)}`, {
signal,
});
if (!response.ok) throw new Error(`Search failed: ${response.status}`);
return response.json();
}encodeURIComponent is not a detail. A query containing & silently truncates without it,
and that is the kind of thing a reviewer notices.
Stage 3 of 5 · ~15 min
Debounce, then cancel
Fewer requests, and only the newest one wins
Two separate problems get solved here, and conflating them is the most common mistake: debouncing reduces how many requests you send, cancellation decides which response is allowed to win. Debouncing alone still lets a slow request land late.
function useSearch(query: string, delay = 250) {
const [state, setState] = useState<SearchState>({ status: "idle" });
useEffect(() => {
if (query.trim() === "") {
setState({ status: "idle" });
return;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
setState((prev) => ({
status: "loading",
query,
previous: prev.status === "ready" ? prev.results : [],
}));
try {
const results = await search(query, controller.signal);
setState({ status: "ready", query, results });
} catch (error) {
if (controller.signal.aborted) return; // superseded, not a failure
setState({ status: "error", query, message: String(error) });
}
}, delay);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query, delay]);
return state;
}The cleanup function is doing double duty: clearTimeout cancels a request that was never
sent, abort cancels one already in flight. Because the effect re-runs on every keystroke,
only the newest request can ever reach setState.
Stage 4 of 5 · ~20 min
Keyboard and screen readers
The combobox pattern, wired properly
This is where most candidates run out of clock, so do it before the pretty styling. A
typeahead is a combobox, and the pattern is fixed — you are not inventing it.
const [active, setActive] = useState(-1);
function onKeyDown(event: React.KeyboardEvent) {
if (event.key === "ArrowDown") {
event.preventDefault(); // stop the caret jumping to the end
setActive((i) => (i + 1) % results.length);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActive((i) => (i - 1 + results.length) % results.length);
} else if (event.key === "Enter" && active >= 0) {
choose(results[active]);
} else if (event.key === "Escape") {
setActive(-1);
close();
}
}Then the wiring that makes it audible as well as visible:
| Attribute | Where | Why |
|---|---|---|
role="combobox" | the input | Declares the pattern |
aria-expanded | the input | Whether the list is open |
aria-activedescendant | the input | Which option is active — focus stays put |
role="listbox" / role="option" | list / rows | Makes the rows options, not divs |
aria-live="polite" | a status node | Announces "8 results" as counts change |
The rule worth saying out loud: focus never leaves the input. Moving DOM focus into the list breaks typing, which is the whole point of a typeahead.
Stage 5 of 5 · ~10 min
Cache, edges and trade-offs
What you would do next, said out loud
With the clock nearly out, spend the last minutes naming what you'd do next rather than half-building it. A cache is the cheapest real win:
const cache = useRef(new Map<string, Result[]>());
const cached = cache.current.get(query);
if (cached) return { status: "ready", query, results: cached };Then the trade-offs an interviewer is actually probing for:
| Decision | What you chose | The cost |
|---|---|---|
| Debounce delay | 250 ms | Under ~150 ms you barely reduce traffic; over ~400 ms it feels laggy |
| Stale results | Kept, dimmed, during load | A flash of the wrong list if the next query is unrelated |
| Cancellation | AbortController | Needs the aborted check before erroring |
| Cache | Unbounded Map per session | Grows forever — swap for an LRU if the session is long |
| Minimum query length | None | One-character queries are expensive and near-useless |
Once you finish
The follow-on asks that arrive when you are done early.
- Cache per query string so retyping a prefix is instant.
- Highlight the matched substring inside each result.
- Recover from a failed request with a retry that doesn't lose the typed query.
- Virtualise the list once results run past a few hundred rows.
What this tests
- async-state
- race-conditions
- accessibility
- keyboard-interaction
- caching