# Build a Search-as-You-Type Input: Debouncing, Cancellation, Edge Cases

Source: https://www.techinterview.org/post/3233475061/build-search-as-you-type-input-frontend/
Updated: 2026-07-26 · techinterview.org

"Build a search-as-you-type input" is one of the most common [frontend interview questions](/category/interview-prep/). It tests whether you understand debouncing, race conditions, request cancellation, and the accessibility patterns of dynamic results — all in 30 minutes.

## Functional requirements

- User types in an input field. Wire it as a controlled component so React state is the single source of truth; read the value from the event, not by querying the DOM.

- Results appear without explicit submit. There is no submit button and no form submission — results update reactively as the debounced value changes, so don't accidentally bind Enter to trigger a search.

- Empty input clears results. When the field goes empty, reset results to an empty array and hide the dropdown rather than firing a request for the empty string.

- Loading state visible while waiting. Show a spinner or skeleton once a request is in flight and remove it when the response resolves or aborts, so the user can tell their typing registered.

- Keyboard nav: arrow keys to highlight, enter to select. Interviewers often push here — you should move a highlighted index with the arrows and commit it with Enter without ever reaching for the mouse.

- Screen reader friendly. The results and their count need to be announced, not just rendered; in practice that means the ARIA combobox pattern plus a live region.

## The core architecture

- onChange handler captures the input value. Read `event.target.value` into state on every keystroke, and keep this handler cheap since it runs on each character.

- Debounce by 200–300ms. Wait until typing pauses before doing any work, so a fast typist triggers one request instead of ten.

- Fetch results when debounce fires. Only the settled value hits the network; make the fetch a function of that debounced value rather than the raw keystroke.

- Cancel any in-flight request before starting a new one. Abort the previous request so a slow earlier query can't overwrite the newest results.

- Update results state when response returns. Set results from the response, but only if it is still the latest request — see race conditions below.

## Debouncing

Naive: setTimeout that clears on each keystroke. Better: use a custom hook or library (lodash.debounce, useDeferredValue from React).

The right delay depends on the API. For fast APIs (<100ms p99): 200ms is comfortable. For slower (300ms+): 350ms.

## Race conditions

The user types "ca" then "cat". The "ca" request is slower and returns after "cat" results are already shown. Without protection, you would overwrite "cat" results with stale "ca" results.

Solutions:

- **AbortController:** abort previous fetch when new fetch starts. Native, ideal.

- **Request ID:** tag each request with an incrementing ID. Compare on response; only update state if the response is for the latest request.

- **Last-write-wins by timestamp:** brittle but works.

## Request cancellation example


```
const abortRef = useRef(null);

const search = async (query) => {
  if (abortRef.current) abortRef.current.abort();
  abortRef.current = new AbortController();
  try {
    const res = await fetch(`/search?q=${query}`, {
      signal: abortRef.current.signal
    });
    const data = await res.json();
    setResults(data);
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
  }
};
```


## Empty state

- Empty input → clear results immediately, no fetch. Reset synchronously so old results don't linger for a full debounce cycle after the user deletes their query.

- Whitespace-only input → treat as empty. Trim before deciding what to do; "   " should behave exactly like an empty field, not a search for spaces.

- Min length (e.g., 2 characters) before searching. A single character usually matches too much to be useful, so gate the fetch behind a minimum length and show a short hint instead of results.

## Accessibility

Use ARIA combobox pattern:

- `role="combobox"` on the input

- `aria-expanded` reflects results visibility

- `aria-controls` points to the results list

- Results list has `role="listbox"`

- Each result has `role="option"` and `aria-selected`

- Live region announces "X results found"

## Keyboard navigation

- Down: next result. Move the highlighted index down by one, wrap to the top (or stop) at the end, and keep the highlighted option scrolled into view.

- Up: previous result. Mirror of Down; from the input with nothing highlighted, Up can jump straight to the last option.

- Enter: select highlighted result. Commit the highlighted option and close the list; if nothing is highlighted, decide whether Enter submits the raw query or does nothing.

- Escape: clear or close. First Escape closes the dropdown, a second can clear the field, and focus stays on the input either way.

- Home/End: first/last result. Jump the highlight to the first or last option, which is handy on long result lists.

## Common mistakes

- No debounce — fires a request on every keystroke

- No cancellation — race conditions display stale results

- No empty-state handling — fires fetch with empty query

- No keyboard support — mouse-only is hostile to power users

- Hardcoded debounce of 500ms — feels laggy

## Frequently Asked Questions

### What if the API does not support cancellation?

Use the request-ID pattern — tag the request, ignore stale responses. The fetch still runs but is harmless.

### Should I cache results?

For repeat queries within a session: yes, useful UX win. Use a small [LRU cache](/problem-index/). Invalidate cache when filters change.

### How do I handle a result list that fits in a dropdown?

Position absolutely below the input. Use focus-trap so keyboard nav stays within the combobox. Close on click-outside.
