FrontendAtlas
Interview Questions
Home>React interview questions> React coding challenges >React autocomplete interview question

Build a React Autocomplete Search Bar

intermediate
By FrontendAtlas Editorial · Updated Jul 14, 2026

Intermediate Frontend machine coding interview question. Recommended time: 35-45 minutes.

35-45 min intermediate level
React state/effects Debounce Async race handling Keyboard navigation Focus management Accessibility Tests
  • Debounced async search and stale-response protection
  • Keyboard navigation, focus management, and ARIA combobox contract
  • Loading, empty, no-results, error, pointer, and cleanup edge cases
Practice a React autocomplete interview question in a live editor. Build a controlled search input with debounce, stale-response protection, keyboard navigation, ARIA, loading, empty, no-results, error states, and runnable tests for a frontend coding interview.
React state/effectsDebounceAsync race handlingKeyboard navigationFocus managementAccessibilityTests

What you'll practice:

  • Frontend machine coding interview question at intermediate level.
  • Recommended time: 35-45 minutes.
  • Build an autocomplete search bar in React, then run checks against visible behavior.

Requirements

  • Render a single App component as the main UI for this first version.
  • Use a controlled query input and keep the query as React state.
  • Debounce search work before filtering suggestions.
  • Filter suggestions case-insensitively by substring match.
  • Show a loading state while the debounced async search is pending.
  • Close the popup and clear results for an empty or whitespace-only query.
  • Show a no-results state when a valid query returns no matches.
  • Show a deterministic error state for a known failing query such as error.
  • Use ArrowDown and ArrowUp with wrap-around navigation.
  • Use Enter to select the active option and Escape to close the popup without changing the query.
  • Close the dropdown on outside pointer interaction.
  • Handle option selection with pointer events so mouse, pen, and touch activation occurs before blur or outside-close timing can swallow it.
  • Clear old options immediately when the query changes, prevent selection while loading, and prevent stale slow responses from overwriting newer results.
  • Clean up timers and document listeners when effects rerun or the component unmounts.
  • Give the combobox an explicit accessible name with a visible label associated through for/id (and aria-labelledby when used).

Acceptance criteria

  • The first screen explains the challenge goal, 35-45 minute timebox, assessed React skills, and Start coding action.
  • The editor preview is meaningful on first load and the page body is useful without waiting for the editor bundle.
  • The solution keeps the first implementation in one component and discusses extraction only as a follow-up.
  • Match highlighting uses React text nodes and mark, not HTML string injection.
  • Async explanation distinguishes debounce timer cleanup from cancelling an already-started request.

Expected behavior

The autocomplete should behave like an editable combobox with list autocomplete.

  • Typing a non-empty query opens the popup, sets loading, then shows matching suggestions after the debounce and fake async search complete.
  • Whitespace-only input closes the popup, clears suggestions, and returns to idle.
  • A query with no matches shows No results rather than an error.
  • The error query shows an error message and does not also show No results.
  • ArrowDown from no active option selects the first option; ArrowUp selects the last option; both wrap around.
  • Enter selects the active option, writes it to the input, and closes the popup.
  • Escape closes the popup while preserving the current query.
  • Pointer selection works for mouse, pen, and touch even when the input would otherwise blur.
  • If san is slow and se is faster, the later se results remain visible when the old san response finishes.
  • When a new non-empty query enters loading, old options disappear immediately and Arrow/Enter cannot select them.

Implementation notes

  • Use query, results, status, isOpen, activeIndex, and a request sequence ref as the core state model.
  • Use setTimeout cleanup to cancel pending debounce work before it starts a request.
  • Use both request identity and AbortController: abort cancellable work, and still guard every async continuation against stale request identity.
  • Clear results and activeIndex in the input-change transition as well as the effect, so passive-effect timing cannot leave stale options interactive.
  • Associate a visible label with the input, then put role="combobox", aria-expanded, aria-autocomplete="list", aria-controls, and aria-activedescendant on it.
  • Put role="listbox" on the popup and role="option" with stable ids and aria-selected on every result.
  • Keep DOM focus on the input while keyboard navigation changes activeIndex.
  • Use pointer events for option activation as an alternative path; keyboard interaction must remain complete and focus stays on the input.

Common mistakes

  • Saying that clearing the debounce timer cancels an already-started request.
  • Using dangerouslySetInnerHTML or HTML strings for highlighting matched text.
  • Using mouse-only or click-only option selection instead of a pointer event that covers mouse, pen, and touch before blur/outside-close races.
  • Moving DOM focus into list options instead of managing aria-activedescendant on the input.
  • Letting an older slow response overwrite results for a newer query.
  • Leaving successful results mounted and selectable while a different query is loading.

Interview explanation

Interviewers evaluate visible correctness, async safety, keyboard and focus behavior, accessibility contracts, tests, and whether you can explain the trade-offs without over-engineering the first version.

Testing checklist

  • Empty or whitespace query clears results and closes the popup.
  • Rapid typing only starts the expected final debounced search.
  • A stale slow response cannot overwrite newer results.
  • Loading opens and closes correctly.
  • Error and no-results states are distinct.
  • ArrowDown and ArrowUp wrap around.
  • Enter selects the active option.
  • Escape closes without changing the query.
  • Outside interaction closes the popup.
  • Pointer option selection covers mouse, pen, and touch semantics and survives blur/outside-close races.
  • aria-controls, aria-activedescendant, and option ids are connected.
  • Unmount leaves no pending debounce timer or document listener.
  • Changing the query clears old options synchronously; ArrowDown and Enter cannot select while loading.
  • The visible label provides the combobox accessible name.

FAQ

Is this the same intent as debounced search?

No. Debounced search practices async search state in general; this challenge owns the React autocomplete implementation with combobox behavior, keyboard navigation, ARIA, and tests.

Should I extract hooks first?

No for the first interview pass. Keep one component until behavior is correct, then discuss hook or component extraction as a senior follow-up.

Solution

Overview

State model

  • Source state: query, results, status, isOpen, activeIndex, and requestSeqRef.
  • Derived state: trimmedQuery, hasResults, active option id, and the visible status text.
  • status should be one of idle, loading, success, empty, or error so loading, no-results, and error cannot collapse into the same branch.

The tempting wrong solution

  • A common first pass starts search(query) after every debounce and sets results when the Promise resolves. Timeline: san starts slowly, se starts later but resolves first, then the old san response resolves and overwrites the se results.
  • Clearing the debounce timer only stops a request that has not started yet. Once fake async work has started, guard the resolution with a request id or ignore flag.

Request ID vs AbortController

  • Request ID or ignore flag: best for fake async work and any uncancellable Promise. It does not stop the work; it only prevents stale state updates.
  • AbortController cancels the current fake delay or real fetch work, while request identity still guards every late continuation. Query change also clears old options immediately so loading never exposes stale selectable data.

Interviewer rubric

  • Visible correctness: controlled input, popup states, selection, and close behavior work.
  • Async safety: debounce cleanup and stale response handling are explained separately.
  • Keyboard/focus behavior: Arrow keys, Enter, Escape, wrap-around, and input focus are correct.
  • Accessibility: combobox/listbox roles, stable ids, aria-controls, aria-activedescendant, and aria-selected are wired.
  • Tests: checks cover loading, empty, error, no-results, stale responses, pointer behavior, and cleanup.
  • Trade-off explanation: first version stays in one component; extraction is a follow-up, not a blocker.

Senior follow-ups

  • Remote API contract, client cache, minimum query length, result limits, virtualization for very large lists, and analytics or observability. Keep this short in the coding interview and use the realtime search system design route for architecture depth.
1

One component, debounced effect, request guard, accessible combobox

  1. Store query, results, status, isOpen, activeIndex, and requestSeqRef.
  2. In the input transition, invalidate the old request and clear results/activeIndex immediately; the query effect then sets loading and starts a debounce timer.
  3. Give each effect an AbortController and request id; update only when it is neither aborted nor stale.
  4. Implement keyboard navigation with wrap-around and keep DOM focus on the input.
  5. Use document pointerdown cleanup for outside close and onPointerDown on options so mouse, pen, and touch select before blur.
  6. Render highlighted matches with React text nodes and mark elements.

Files

{
  "name": "react-autocomplete-search-solution",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "react": "18.x",
    "react-dom": "18.x",
    "react-scripts": "5.0.1"
  },
  "scripts": {
    "start": "react-scripts start"
  }
}
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "target": "ES2020",
    "module": "ESNext",
    "strict": true
  }
}
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>React SDK - Autocomplete</title></head><body><div id="root"></div></body></html>
:root {
  color-scheme: dark;
}

html,body {
  margin: 0;
  min-height: 100%;
  background: #0f1117;
}

body {
  font-family: Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
}

* {
  box-sizing: border-box;
}

.page {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
  background: #0f1117;
  color: #f4f7fb;
}

.card {
  width: 100%;
  max-width: 560px;
  background: #171b24;
  border: 1px solid #2d3443;
  border-radius: 8px;
  padding: 20px;
  display: grid;
  gap: 16px;
  box-shadow: 0 18px 48px rgba(0,0,0,.28);
}

.heading {
  display: grid;
  gap: 6px;
}

.eyebrow {
  margin: 0;
  color: #9fb0c7;
  font-size: 12px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .06em;
}

.title {
  margin: 0;
  font-size: 22px;
  line-height: 1.2;
  font-weight: 800;
  letter-spacing: 0;
}

.intro {
  margin: 0;
  color: #c7d2e1;
  font-size: 14px;
  line-height: 1.5;
}

.label {
  display: block;
  font-size: 13px;
  color: #c7d2e1;
  margin-bottom: 6px;
  font-weight: 700;
}

.combo {
  position: relative;
}

.input {
  width: 100%;
  min-height: 44px;
  padding: 11px 12px;
  border-radius: 8px;
  border: 1px solid #3d4657;
  background: #10141d;
  color: #f4f7fb;
  outline: none;
  font-size: 15px;
}

.input::placeholder {
  color: #7f8ba0;
}

.input:focus {
  border-color: #6ee7b7;
  box-shadow: 0 0 0 3px rgba(110,231,183,.16);
}

.list {
  position: absolute;
  z-index: 10;
  left: 0;
  right: 0;
  margin: 8px 0 0;
  padding: 6px;
  max-height: 250px;
  overflow: auto;
  border-radius: 8px;
  border: 1px solid #2d3443;
  background: #10141d;
  box-shadow: 0 18px 36px rgba(0,0,0,.36);
  list-style: none;
}

.option,.status {
  min-height: 38px;
  display: flex;
  align-items: center;
  padding: 9px 10px;
  border-radius: 6px;
  font-size: 14px;
  line-height: 1.35;
}

.option {
  cursor: pointer;
  color: #f4f7fb;
}

.option:hover,.option.active {
  background: #243044;
}

.option[aria-selected="true"] {
  outline: 1px solid rgba(110,231,183,.55);
}

.status {
  color: #9fb0c7;
}

.status--error {
  color: #fecaca;
}

.hint {
  margin: 0;
  color: #9fb0c7;
  font-size: 12px;
  line-height: 1.45;
}

mark {
  background: rgba(110,231,183,.25);
  color: #f4f7fb;
  border-radius: 3px;
  padding: 0 2px;
}
import React, { useEffect, useRef, useState } from 'react';
import './App.css';

const CITIES: string[] = [
  'Amsterdam', 'Ankara', 'Athens', 'Austin',
  'Barcelona', 'Berlin', 'Boston', 'Brussels',
  'Chicago', 'Copenhagen',
  'Dubai',
  'Edinburgh',
  'Frankfurt',
  'Hamburg',
  'Istanbul', 'Izmir',
  'London', 'Los Angeles',
  'Madrid', 'Miami', 'Milan', 'Munich',
  'New York',
  'Oslo',
  'Paris', 'Prague',
  'Rome',
  'San Diego', 'San Francisco', 'San Jose', 'Seattle', 'Seoul',
  'Tokyo',
  'Vienna',
  'Zurich'
];

type Status = 'idle' | 'loading' | 'success' | 'empty' | 'error';

function abortError(): DOMException {
  return new DOMException('Search aborted', 'AbortError');
}

function sleep(ms: number, signal: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    let timerId: number | null = null;

    const cleanup = () => signal.removeEventListener('abort', onAbort);
    const onAbort = () => {
      if (timerId !== null) window.clearTimeout(timerId);
      cleanup();
      reject(abortError());
    };

    if (signal.aborted) {
      onAbort();
      return;
    }

    signal.addEventListener('abort', onAbort, { once: true });
    timerId = window.setTimeout(() => {
      cleanup();
      resolve();
    }, ms);
  });
}

function isAbortError(error: unknown): boolean {
  return error instanceof DOMException && error.name === 'AbortError';
}

function optionId(item: string): string {
  return 'autocomplete-option-' + item.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}

export default function App() {
  const rootRef = useRef<HTMLDivElement | null>(null);
  const inputRef = useRef<HTMLInputElement | null>(null);
  const requestSeqRef = useRef(0);
  const selectedQueryRef = useRef('');

  const listboxId = 'autocomplete-listbox';
  const labelId = 'autocomplete-label';

  const [query, setQuery] = useState('');
  const [status, setStatus] = useState<Status>('idle');
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const [results, setResults] = useState<string[]>([]);

  useEffect(() => {
    const trimmed = query.trim();

    if (selectedQueryRef.current && selectedQueryRef.current === query) {
      selectedQueryRef.current = '';
      return;
    }

    if (!trimmed) {
      setResults([]);
      setStatus('idle');
      setIsOpen(false);
      setActiveIndex(-1);
      return;
    }

    let ignore = false;
    const controller = new AbortController();
    const requestId = ++requestSeqRef.current;

    setResults([]);
    setStatus('loading');
    setIsOpen(true);
    setActiveIndex(-1);

    const debounceId = window.setTimeout(async () => {
      try {
        const nextResults = await searchCities(trimmed, controller.signal);
        if (ignore || controller.signal.aborted || requestSeqRef.current !== requestId) return;
        setResults(nextResults);
        setStatus(nextResults.length ? 'success' : 'empty');
        setIsOpen(true);
        setActiveIndex(-1);
      } catch (error) {
        if (
          ignore ||
          controller.signal.aborted ||
          requestSeqRef.current !== requestId ||
          isAbortError(error)
        ) return;
        setResults([]);
        setStatus('error');
        setIsOpen(true);
        setActiveIndex(-1);
      }
    }, 300);

    return () => {
      ignore = true;
      controller.abort();
      window.clearTimeout(debounceId);
    };
  }, [query]);

  useEffect(() => {
    function onDocumentPointerDown(event: PointerEvent) {
      const root = rootRef.current;
      const target = event.target as Node | null;
      if (!root || !target || root.contains(target)) return;
      setIsOpen(false);
      setActiveIndex(-1);
    }

    document.addEventListener('pointerdown', onDocumentPointerDown);
    return () => document.removeEventListener('pointerdown', onDocumentPointerDown);
  }, []);

  function handleInputChange(value: string) {
    selectedQueryRef.current = '';
    requestSeqRef.current += 1;
    setQuery(value);
    setResults([]);
    setActiveIndex(-1);

    if (value.trim()) {
      setStatus('loading');
      setIsOpen(true);
    } else {
      setStatus('idle');
      setIsOpen(false);
    }
  }

  function handleFocus() {
    if (selectedQueryRef.current) return;
    if (query.trim() && (results.length > 0 || status === 'loading' || status === 'empty' || status === 'error')) {
      setIsOpen(true);
    }
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
    if (event.key === 'Escape') {
      event.preventDefault();
      setIsOpen(false);
      setActiveIndex(-1);
      return;
    }

    if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
      event.preventDefault();
      if (!isOpen && query.trim()) setIsOpen(true);
      if (status !== 'success' || !results.length) return;

      setActiveIndex((current) => {
        if (event.key === 'ArrowDown') {
          return current < 0 ? 0 : (current + 1) % results.length;
        }
        return current < 0 ? results.length - 1 : (current - 1 + results.length) % results.length;
      });
      return;
    }

    if (event.key === 'Enter') {
      if (status !== 'success' || !isOpen || activeIndex < 0 || activeIndex >= results.length) return;
      event.preventDefault();
      selectOption(results[activeIndex]);
    }
  }

  function selectOption(value: string) {
    if (status !== 'success' || !results.includes(value)) return;
    selectedQueryRef.current = value;
    requestSeqRef.current += 1;
    setQuery(value);
    setResults([]);
    setStatus('idle');
    setIsOpen(false);
    setActiveIndex(-1);
    inputRef.current?.focus();
  }

  const visibleResults = status === 'success' ? results : [];
  const activeId = activeIndex >= 0 && visibleResults[activeIndex]
    ? optionId(visibleResults[activeIndex])
    : undefined;

  return (
    <main className="page">
      <section className="card" aria-labelledby="autocomplete-title">
        <div className="heading">
          <p className="eyebrow">React autocomplete interview question</p>
          <h1 id="autocomplete-title" className="title">Autocomplete search</h1>
          <p className="intro">Build debounce, stale-response handling, keyboard navigation, ARIA, and tests.</p>
        </div>

        <div ref={rootRef} className="combo">
          <label id={labelId} className="label" htmlFor="autocomplete-query">Search cities</label>
          <input
            ref={inputRef}
            id="autocomplete-query"
            className="input"
            role="combobox"
            aria-labelledby={labelId}
            aria-expanded={isOpen}
            aria-autocomplete="list"
            aria-controls={listboxId}
            aria-activedescendant={activeId}
            type="text"
            autoComplete="off"
            placeholder="Type san, sea, lon, or error"
            value={query}
            onChange={(event) => handleInputChange(event.target.value)}
            onFocus={handleFocus}
            onKeyDown={handleKeyDown}
          />

          {isOpen && (
            <ul className="list" id={listboxId} role="listbox" aria-label="City suggestions">
              {visibleResults.map((item, index) => (
                <li
                  key={item}
                  id={optionId(item)}
                  className={index === activeIndex ? 'option active' : 'option'}
                  role="option"
                  aria-selected={index === activeIndex}
                  onPointerDown={(event) => {
                    event.preventDefault();
                    selectOption(item);
                  }}
                >
                  {renderHighlighted(item, query)}
                </li>
              ))}
              {status === 'loading' && <li className="status">Loading suggestions for "{query.trim()}"...</li>}
              {status === 'empty' && <li className="status">No results</li>}
              {status === 'error' && <li className="status status--error">Search failed. Try a different query.</li>}
            </ul>
          )}
        </div>

        <p className="hint">Keep focus on the input. Use ArrowUp, ArrowDown, Enter, and Escape.</p>
      </section>
    </main>
  );
}

async function searchCities(rawQuery: string, signal: AbortSignal): Promise<string[]> {
  const query = rawQuery.trim().toLowerCase();
  const delay = query === 'san' ? 450 : query === 'se' ? 80 : 120;

  await sleep(delay, signal);

  if (query === 'error') {
    throw new Error('Deterministic search failure');
  }

  return CITIES
    .filter((city) => city.toLowerCase().includes(query))
    .slice(0, 8);
}

function renderHighlighted(item: string, rawQuery: string) {
  const query = rawQuery.trim();
  if (!query) return item;

  const index = item.toLowerCase().indexOf(query.toLowerCase());
  if (index < 0) return item;

  const before = item.slice(0, index);
  const match = item.slice(index, index + query.length);
  const after = item.slice(index + query.length);

  return (
    <>
      {before}
      <mark>{match}</mark>
      {after}
    </>
  );
}
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';

createRoot(document.getElementById('root')!).render(<App />);

Notes & Pitfalls

Pitfalls
  • Do not describe debounce cleanup as network cancellation.
  • Do not use dangerouslySetInnerHTML for highlight markup.
  • Do not let no-results and error share the same UI state.
  • Do not move focus from the input to list options during keyboard navigation.
  • Do not expose previous-query options while a new query is loading.
Edge cases
  • Whitespace-only query
  • Slow stale response after a newer query
  • Pointer selection while the input would blur
  • Unmount with pending debounce and document listener
  • A new query while old successful options are active
Techniques
  • Request sequence refs for uncancellable fake async work
  • AbortController plus request identity for cancellation and stale-continuation safety
  • aria-activedescendant for input-owned active option state
  • Modulo arithmetic for wrap-around navigation

Common mistakes on this challenge

  • Treat autocomplete as async state plus combobox behavior

    This prompt tests debounce, stale-response protection, keyboard navigation, ARIA wiring, and cleanup together.

    • Model `query`, `results`, `status`, `isOpen`, and `activeIndex` separately so loading, no-results, and error states do not overlap.
    • Use effect cleanup for the debounce timer, and also guard resolved async work with a request id or ignore flag.
    • Keep DOM focus on the input; drive ArrowUp/ArrowDown/Enter/Escape through `activeIndex` and `aria-activedescendant`.
    • Use pointer or mousedown selection before outside-close timing can swallow option selection.

Common interview follow-ups:

  • How would you add a remote API contract and retries without making the component harder to test?
  • When would you introduce a client cache or minimum query length?
  • How would result limits and virtualization change the rendering path for very large lists?
  • Which analytics events would you track for search quality and abandonment?

Related links

React debounced search state practiceNext drillMachine coding interview hubHubReact interview preparation pathGuideAutocomplete and realtime search system designSystem designFrontend Essential 60 practice setSet
↗Incidents hub
2 / 28