Autocomplete
Debounced search-as-you-type with a dropdown of suggestions. Uses `useDeferredValue` for React-idiomatic debounce and `multi-search` to fetch products + categories in one round trip.
A responsive autocomplete needs to balance latency against request volume. This recipe debounces input client-side, fetches via multi-search for product + category suggestions, and cancels stale requests on each new keystroke.
What you build
A <SearchAutocomplete> React component that:
- Debounces input (200 ms via
useDeferredValue). - Returns 5 product suggestions + 3 category suggestions in one request.
- Cancels in-flight requests when a new query arrives.
- Renders a keyboard-navigable dropdown.
- Tracks a
search_queryevent for analytics.
Full component
"use client";
import { useDeferredValue, useEffect, useRef, useState } from "react";
import { SearchClient } from "@aacsearch/client";
const client = new SearchClient({
baseUrl: process.env.NEXT_PUBLIC_AACSEARCH_BASE_URL!,
apiKey: process.env.NEXT_PUBLIC_AACSEARCH_SEARCH_KEY!,
indexSlug: "products",
});
type Suggestion = {
products: Array<{ id: string; title: string; price: number }>;
categories: Array<{ id: string; name: string; productCount: number }>;
};
export function SearchAutocomplete() {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const [suggestions, setSuggestions] = useState<Suggestion | null>(null);
const [isOpen, setIsOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
if (!deferredQuery.trim()) {
setSuggestions(null);
return;
}
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
(async () => {
try {
const { results } = await client.multiSearch(
[
{
indexSlug: "products",
q: deferredQuery,
queryBy: "title,brand,sku",
perPage: 5,
highlightFields: "title",
},
{
indexSlug: "categories",
q: deferredQuery,
queryBy: "name",
perPage: 3,
},
],
{ signal: ctrl.signal },
);
setSuggestions({
products: results[0].hits.map((h) => h.document),
categories: results[1].hits.map((h) => h.document),
});
setIsOpen(true);
} catch (err) {
if ((err as DOMException).name === "AbortError") return;
console.error("autocomplete failed:", err);
}
})();
return () => ctrl.abort();
}, [deferredQuery]);
return (
<div className="search-autocomplete" data-open={isOpen}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setIsOpen(true)}
onBlur={() => setTimeout(() => setIsOpen(false), 150)}
placeholder="Search products"
aria-autocomplete="list"
/>
{isOpen && suggestions && (
<div role="listbox">
{suggestions.products.map((p) => (
<a key={p.id} href={`/products/${p.id}`} role="option">
{p.title} — ${p.price.toFixed(2)}
</a>
))}
{suggestions.categories.map((c) => (
<a key={c.id} href={`/c/${c.id}`} role="option">
In {c.name} ({c.productCount})
</a>
))}
</div>
)}
</div>
);
}Why useDeferredValue instead of a setTimeout debounce
useDeferredValue lets React render the input immediately and defer the dependent fetch until the user stops typing. It plays nicely with concurrent rendering — a debounce timer does not. For React 18+, prefer it.
For non-React or React < 18, use a small debounce hook:
function useDebouncedValue<T>(value: T, delayMs = 200) {
const [v, setV] = useState(value);
useEffect(() => {
const id = setTimeout(() => setV(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);
return v;
}Cancellation
AbortController keeps a tight grip on stale requests — without it, a slow first request can land after a fast second request and overwrite the dropdown with old data. The SDK forwards signal to the underlying fetch.
Highlighting
Adding highlightFields: "title" returns matched substrings wrapped in <mark> tags inside hit.highlights.title.snippet. To render:
<a key={p.id} href={`/products/${p.id}`}
dangerouslySetInnerHTML={{ __html: hit.highlights?.title?.snippet ?? p.title }} />dangerouslySetInnerHTML is safe here because AACsearch sanitizes highlighted output (only <mark> tags are emitted). Do not use it with untrusted text from elsewhere.
Tracking the query
For relevance tuning, send the query as a search_query event after results land:
await fetch("/api/events/track", {
method: "POST",
headers: { Authorization: `Bearer ${publicKey}`, "Content-Type": "application/json" },
body: JSON.stringify({
event: "search_query",
properties: { query: deferredQuery, resultsCount: results[0].found },
}),
});Vanilla JS variant
<input id="q" placeholder="Search" />
<div id="suggestions"></div>
<script type="module">
import { SearchClient } from "https://esm.sh/@aacsearch/client";
const client = new SearchClient({
baseUrl: "https://app.aacsearch.com",
apiKey: "ss_search_...",
indexSlug: "products",
});
let timer;
let ctrl;
document.getElementById("q").addEventListener("input", (e) => {
clearTimeout(timer);
ctrl?.abort();
timer = setTimeout(async () => {
ctrl = new AbortController();
const r = await client.search({ q: e.target.value, perPage: 5 }, { signal: ctrl.signal });
document.getElementById("suggestions").innerHTML = r.hits
.map((h) => `<a href="/products/${h.document.id}">${h.document.title}</a>`)
.join("");
}, 200);
});
</script>Related
Cookbook
Copy-paste recipes for the most common AACsearch SDK patterns — autocomplete, faceted search, product listings, click tracking, scoped tokens, multi-tenant, multi-locale, and graceful failure.
Faceted search
Multi-select filter sidebar (brand, category, price range) driven by AACsearch facet counts. Filters AND-combined; counts updated reactively.