Autocomplete Search Bar
An autocomplete search bar suggests possible queries or entities while the user types.
User types a query
↓
Wait briefly for typing to pause
↓
Request or compute suggestions
↓
Rank and display a short list
↓
User selects a suggestion or submits the query
Autocomplete is also called typeahead or search suggestions.
Core idea: Keep typing immediate, make suggestion work cancellable and bounded, and treat the popup as a complete keyboard-accessible combobox interaction.
1. Autocomplete Is Not the Results Page
Autocomplete helps the user form or select a query before a full search.
| Autocomplete suggestions | Search results |
|---|---|
| Small, fast response | Larger and more detailed response |
| Optimized for each query prefix | Optimized for a submitted query |
| Usually 5-10 choices | Often paginated |
| May include recent/popular queries | Includes matched documents or products |
| Selection can fill or submit input | Selection opens a result |
The suggestion endpoint should not return an entire search-results payload on every keystroke.
2. Main State
A production component usually needs more than one text state.
const [inputValue, setInputValue] = useState("");
const [suggestions, setSuggestions] = useState([]);
const [activeIndex, setActiveIndex] = useState(-1);
const [status, setStatus] = useState("idle");
const [isOpen, setIsOpen] = useState(false);
| State | Purpose |
|---|---|
inputValue | Text currently shown in the input |
suggestions | Latest valid suggestion records |
activeIndex | Keyboard-highlighted option, or -1 |
status | idle, loading, success, or error |
isOpen | Whether the suggestion popup is exposed |
Keep keyboard focus, active option, and committed selection conceptually separate. Moving through suggestions does not have to submit or permanently replace what the user typed.
3. Controlled Input Flow
<label htmlFor="site-search">Search</label>
<input
id="site-search"
type="search"
value={inputValue}
onChange={event => setInputValue(event.target.value)}
/>
User enters a character
↓
onChange receives the complete input value
↓
React state updates
↓
Input re-renders immediately
Do not debounce the value displayed in the input. Debounce the expensive work derived from that value.
4. Suggestion API Contract
Use an application-owned endpoint with a small, stable response.
GET /api/search/suggestions?q=lap&limit=8&locale=en-IN
{
"query": "lap",
"suggestions": [
{
"id": "query:laptop",
"type": "query",
"label": "laptop",
"metadata": "Popular search"
},
{
"id": "category:laptop-accessories",
"type": "category",
"label": "Laptop accessories",
"url": "/categories/laptop-accessories"
}
]
}
Return stable IDs and structured data rather than strings alone. The UI can then render categories, products, recent searches, or commands differently without guessing from text.
The server should cap the result count even if a larger limit is requested.
5. Minimum Query Length
Very short prefixes often match too many records and provide little intent.
const normalizedQuery = inputValue.trim();
if (normalizedQuery.length < 2) {
setSuggestions([]);
setIsOpen(false);
return;
}
The threshold is a product decision. A media title search may support one character; a large people directory may require two or three.
An empty focused field can show recent searches or trending suggestions, but those are not query-dependent autocomplete and should be labelled appropriately.
6. Debouncing
Debouncing waits until input has remained unchanged for a short period.
User types “l” → timer starts
User types “la” → previous timer is cancelled
User types “lap” → previous timer is cancelled
250 ms passes → request “lap”
Reusable Hook
import { useEffect, useState } from "react";
function useDebouncedValue(value, delayMs) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timerId = window.setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
return () => window.clearTimeout(timerId);
}, [value, delayMs]);
return debouncedValue;
}
const debouncedQuery = useDebouncedValue(inputValue.trim(), 250);
A longer delay reduces requests but makes suggestions feel slower. Start with a modest value, measure real typing and latency, and adjust by device, network, or query cost if necessary.
7. Debouncing Does Not Solve Request Races
Two requests can still overlap:
Request “lap” starts
Request “lapt” starts
“lapt” returns first
“lap” returns last
Without protection, the older response can overwrite newer suggestions.
Use AbortController to cancel obsolete work:
useEffect(() => {
if (debouncedQuery.length < 2) {
setSuggestions([]);
setStatus("idle");
return;
}
const controller = new AbortController();
async function loadSuggestions() {
setStatus("loading");
try {
const params = new URLSearchParams({
q: debouncedQuery,
limit: "8",
});
const response = await fetch(
`/api/search/suggestions?${params}`,
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error("Could not load suggestions");
}
const result = await response.json();
if (!controller.signal.aborted) {
setSuggestions(result.suggestions);
setActiveIndex(-1);
setStatus("success");
setIsOpen(true);
}
} catch (requestError) {
if (requestError.name !== "AbortError") {
setSuggestions([]);
setStatus("error");
}
}
}
loadSuggestions();
return () => controller.abort();
}, [debouncedQuery]);
Debounce controls when a request begins. Cancellation controls whether an obsolete request may finish and update state.
8. Throttling vs Debouncing
| Debouncing | Throttling |
|---|---|
| Runs after activity pauses | Runs at most once per interval |
| Good for text suggestions | Good for continuous pointer or scroll updates |
| May wait while typing continues | Produces periodic intermediate updates |
Autocomplete usually starts with debouncing because the final prefix is more useful than every intermediate prefix. A specialized system may combine a maximum wait with debouncing so suggestions still update during long continuous typing.
9. Accessible Combobox Structure
An editable autocomplete with a suggestion popup follows the combobox and listbox pattern.
import { useId, useState } from "react";
function SearchCombobox({ suggestions, onSearch, onSelect }) {
const listboxId = useId();
const [inputValue, setInputValue] = useState("");
const [activeIndex, setActiveIndex] = useState(-1);
const [isOpen, setIsOpen] = useState(false);
const hasOptions = suggestions.length > 0;
const popupVisible = isOpen && hasOptions;
const activeOption = suggestions[activeIndex];
function optionId(index) {
return `${listboxId}-option-${index}`;
}
function handleKeyDown(event) {
if (event.key === "ArrowDown" && hasOptions) {
event.preventDefault();
setIsOpen(true);
setActiveIndex(index =>
index < suggestions.length - 1 ? index + 1 : 0
);
}
if (event.key === "ArrowUp" && hasOptions) {
event.preventDefault();
setIsOpen(true);
setActiveIndex(index =>
index > 0 ? index - 1 : suggestions.length - 1
);
}
if (event.key === "Enter") {
event.preventDefault();
if (popupVisible && activeOption) {
chooseSuggestion(activeOption);
} else {
onSearch(inputValue.trim());
setIsOpen(false);
}
}
if (event.key === "Escape") {
setIsOpen(false);
setActiveIndex(-1);
}
}
function chooseSuggestion(suggestion) {
setInputValue(suggestion.label);
setIsOpen(false);
setActiveIndex(-1);
onSelect(suggestion);
}
return (
<div className="search-combobox">
<label htmlFor={`${listboxId}-input`}>Search</label>
<input
id={`${listboxId}-input`}
type="search"
role="combobox"
aria-autocomplete="list"
aria-expanded={popupVisible}
aria-controls={listboxId}
aria-activedescendant={
activeIndex >= 0 ? optionId(activeIndex) : undefined
}
autoComplete="off"
value={inputValue}
onChange={event => {
setInputValue(event.target.value);
setActiveIndex(-1);
setIsOpen(true);
}}
onFocus={() => hasOptions && setIsOpen(true)}
onBlur={() => setIsOpen(false)}
onKeyDown={handleKeyDown}
/>
{popupVisible && (
<ul id={listboxId} role="listbox">
{suggestions.map((suggestion, index) => (
<li
key={suggestion.id}
id={optionId(index)}
role="option"
aria-selected={index === activeIndex}
onPointerDown={event => event.preventDefault()}
onPointerMove={() => setActiveIndex(index)}
onClick={() => chooseSuggestion(suggestion)}
>
{suggestion.label}
</li>
))}
</ul>
)}
</div>
);
}
DOM focus remains on the input. aria-activedescendant tells assistive technology which option is active while Arrow keys change the highlighted index.
onPointerDown prevents the input from blurring before the option's click handler runs. Test this behavior with mouse, touch, keyboard, and assistive technology rather than relying on timing delays.
10. Keyboard Interaction
| Key | Expected behavior |
|---|---|
| Printable characters | Edit the input normally |
Arrow Down | Open the popup and move to the next option |
Arrow Up | Open the popup and move to the previous option |
Enter | Choose the active option or submit the typed query |
Escape | Close the popup without unexpectedly changing the input |
Tab | Move to the next focusable control using normal page order |
Do not override standard text-editing shortcuts such as cursor movement, selection, copy, paste, or platform-specific commands.
The popup options normally do not enter the page's Tab sequence. Arrow keys move the active option while focus stays in the text field.
11. Open, Close, and Blur Behavior
“Close on blur” sounds simple, but clicking an option usually blurs the input before the click fires.
Possible strategies include:
- prevent pointer-down from moving focus;
- close only when focus leaves the complete combobox container;
- handle outside interaction with a document-level pointer listener;
- use a tested accessible combobox primitive from the design system.
Avoid arbitrary setTimeout delays to make option clicks work. Timing workarounds are fragile across touch, keyboard, and assistive technologies.
The popup should close when:
- the user presses Escape;
- a suggestion is committed;
- focus or pointer interaction moves outside according to the product behavior;
- the query is too short or has no displayable suggestions;
- navigation leaves the current view.
12. Loading, Empty, and Error States
Suggestions can be slow or unavailable.
| State | Possible UI |
|---|---|
| Query too short | Hide popup or show recent searches |
| Debouncing | Usually keep the current stable UI |
| Loading | Small progress indicator and aria-busy |
| Results | List of ranked suggestions |
| No matches | “No suggestions found” |
| Error | Quiet fallback that still allows full search |
Autocomplete failure should not block ordinary search submission.
Use a restrained status region for meaningful announcements:
<div role="status" aria-live="polite" className="visually-hidden">
{status === "loading" && "Loading suggestions"}
{status === "success" && `${suggestions.length} suggestions available`}
</div>
Avoid announcing every keystroke or every highlighted option twice.
13. Caching Suggestions
Users often delete and retype the same prefixes:
lap → lapt → lap → lapt
A short-lived cache can avoid repeated network requests.
const cacheKey = JSON.stringify({
query: normalizedQuery.toLocaleLowerCase(locale),
locale,
category,
});
Cache by every value that affects results, including locale, permissions, tenant, filters, or personalization scope.
Use:
- a bounded LRU cache;
- a short time-to-live;
- invalidation for changing catalogs;
- no shared caching for sensitive personalized queries unless isolation is correct.
An unbounded object cache becomes a memory leak during a long session.
14. Prefetching and Local Suggestions
Not every suggestion requires a network call.
Possible local sources include:
- recent searches stored with user consent;
- a small static command list;
- recently viewed products;
- server-rendered popular searches;
- cached prefixes.
A layered strategy can render local suggestions immediately and merge or replace them with server-ranked suggestions later.
Keep source identity in each record so analytics and selection behavior remain correct.
15. Server-Side Suggestion Architecture
Typed prefix
↓
Normalize and validate query
↓
Check hot-prefix cache
↓ miss
Query prefix/search index
↓
Apply permissions and business rules
↓
Rank candidates
↓
Return a small bounded response
Candidate sources can include:
- prefix indexes or tries;
- search-engine completion indexes;
- product or entity search;
- popularity and trend data;
- the user's permitted recent history;
- spelling and synonym models.
The browser should not download a large private dataset simply to filter it locally.
16. Ranking Suggestions
Matching text is only candidate generation. Ranking determines order.
Signals may include:
- exact prefix match;
- popularity;
- freshness;
- current category or location;
- language and locale;
- user permissions;
- personalization with consent;
- spelling distance;
- business quality or safety rules.
score = text relevance
+ popularity weight
+ freshness weight
+ context weight
- policy penalties
Do not let sponsored or promoted suggestions become indistinguishable from ordinary suggestions. Label them clearly.
17. Highlight Matching Text Safely
Avoid injecting HTML to add <mark> tags. Split the trusted display string into React text nodes.
function HighlightMatch({ text, query }) {
const start = text.toLocaleLowerCase().indexOf(
query.toLocaleLowerCase()
);
if (start < 0 || query.length === 0) return text;
const end = start + query.length;
return (
<>
{text.slice(0, start)}
<mark>{text.slice(start, end)}</mark>
{text.slice(end)}
</>
);
}
Real international text matching may require locale-aware normalization, tokenization, and grapheme handling. The server can return matched ranges when its matching rules are more sophisticated than a client substring search.
18. Input Method Editors and Composition
Users entering Chinese, Japanese, Korean, and other text may compose multiple keystrokes before committing a character.
Avoid requesting suggestions for incomplete composition text:
const [isComposing, setIsComposing] = useState(false);
<input
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={event => {
setIsComposing(false);
setInputValue(event.currentTarget.value);
}}
/>
Only trigger query work when composition has ended, while still allowing the browser and input method to manage the text normally.
Test with real international input methods rather than assuming ordinary Latin keyboard events represent every user.
19. Search Submission and URL State
Typing suggestions is temporary UI state. A committed search is often navigable URL state.
/search?q=laptop
On Enter with no active suggestion, submit the typed query. When a suggestion is selected:
- a query suggestion may navigate to search results;
- a category suggestion may open that category;
- a product suggestion may navigate directly to the product;
- a command suggestion may perform an application action.
Do not put every intermediate keystroke in browser history. Update the URL when the user commits a search unless live URL updates are an intentional requirement.
20. Performance Considerations
- Keep the suggestion count small.
- Debounce expensive work.
- Cancel obsolete requests.
- Cache hot prefixes with a bound and TTL.
- Avoid rendering large rich cards in the popup.
- Reuse one suggestion endpoint rather than issuing separate calls per source from the browser.
- Measure time from input change to useful suggestions.
- Preconnect only when the endpoint is predictably used and the connection cost is justified.
Autocomplete usually does not need virtualization because the popup should contain a short list. If hundreds of choices are required, reconsider the interaction or use a more suitable searchable selection component.
21. Security, Privacy, and Abuse Prevention
Search queries can reveal sensitive intent.
Protect the system by:
- sending requests over HTTPS;
- encoding query parameters with
URLSearchParams; - enforcing server-side length and rate limits;
- authenticating and authorizing private entity suggestions;
- escaping or safely rendering suggestion text;
- avoiding raw HTML from the server;
- validating destination URLs and actions;
- minimizing query logs and applying retention policy;
- preventing cross-tenant cache leakage;
- defending the endpoint against automated scraping and enumeration.
Do not expose a private user, document, or product in suggestions merely because its name matches the typed prefix.
22. Analytics and Quality
Useful metrics include:
- suggestion response latency;
- request, error, cancellation, and cache-hit rate;
- percentage of queries with suggestions;
- suggestion impression-to-selection rate;
- position of selected suggestion;
- full-search submission rate;
- zero-result rate after selection;
- abandonment after typing;
- keyboard versus pointer selection where privacy policy allows.
Avoid logging every raw keystroke as an unrestricted analytics event. Aggregate, sample, redact, or tokenize data according to privacy requirements.
Quality evaluation should include relevance and task success, not only click-through rate. A misleading suggestion can attract clicks and still create a poor experience.
23. Common Mistakes
| Mistake | Better approach |
|---|---|
| Sending a request for every keystroke | Debounce query work |
| Debouncing the visible input value | Keep input immediate; debounce the derived query |
| Assuming debounce prevents stale responses | Cancel or ignore obsolete requests |
| Fetching for empty or one-character input without need | Apply a product-appropriate minimum length |
| Moving DOM focus through every option | Keep focus in the input and expose the active option |
| Supporting mouse but not Arrow keys and Enter | Implement the complete combobox keyboard model |
| Closing on blur before an option click | Coordinate pointer-down and focus behavior |
| Using list indexes as keys | Use stable suggestion IDs |
| Caching only by query text | Include locale, scope, filters, and permissions |
| Keeping an unbounded cache | Use LRU and TTL limits |
| Rendering highlighted text with raw HTML | Split text into safe React nodes |
| Blocking full search when suggestions fail | Keep ordinary search available |
| Requesting during IME composition | Wait for composition to finish |
| Logging every raw prefix indefinitely | Minimize and protect query analytics |
24. Testing Checklist
Input and Requests
- Typing updates the input without delay.
- Requests begin only after the debounce period.
- Empty and short queries do not request unexpectedly.
- An old response cannot overwrite a new query.
- Clearing the field cancels the active request and closes the popup.
- Errors preserve ordinary search submission.
- Cache keys separate locale, scope, and filter variants.
Keyboard and Focus
- Arrow Down and Arrow Up move through options.
- Enter selects the active option or submits free text.
- Escape closes without corrupting input.
- Tab leaves the widget normally.
- Focus remains on the input during option navigation.
- Pointer selection works without premature blur.
- Standard text-editing shortcuts still work.
Accessibility
- The input has a persistent visible label.
aria-expandedmatches popup visibility.aria-controlsreferences the listbox.aria-activedescendantreferences the active option.- Exactly one option is selected visually and programmatically.
- Loading and result counts are announced without excessive noise.
Internationalization and Security
- IME composition does not cause partial-query requests.
- Accents, casing, non-Latin scripts, and right-to-left layouts work.
- Long and malicious input is bounded and safely rendered.
- Private results never appear for unauthorized users.
- Suggestion destinations cannot inject unsafe URLs or code.
Official References
- W3C WAI: Combobox Pattern
- WAI-ARIA 1.2: Combobox and
aria-autocomplete - MDN:
AbortController - MDN:
<input type="search">
Summary
A good autocomplete search bar combines immediate input with delayed, cancellable suggestion work and a complete accessible combobox interaction.
Think of autocomplete as three connected pipelines:
Input pipeline
type → compose → normalize → debounce
Data pipeline
cache → request → cancel stale → rank → return bounded suggestions
Interaction pipeline
open → announce → navigate active option → select or submit → close
Fetching suggestions is only one part. Request correctness, ranking, focus management, privacy, and graceful fallback determine whether autocomplete actually helps the user.