AACsearch
SDKsCookbook

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_query event 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>

On this page