Автодополнение
Поиск с задержкой по мере ввода и выпадающим списком подсказок. Использует `useDeferredValue` для React-идиоматичного debounce и `multi-search` для получения товаров и категорий в одном запросе.
Отзывчивое автодополнение должно балансировать между задержкой и объёмом запросов. Этот рецепт выполняет debounce ввода на клиенте, получает данные через multi-search для подсказок товаров и категорий, и отменяет устаревшие запросы при каждом новом нажатии клавиши.
Что вы создаёте
React-компонент <SearchAutocomplete>, который:
- Выполняет debounce ввода (200 мс через
useDeferredValue). - Возвращает 5 подсказок товаров + 3 подсказки категорий в одном запросе.
- Отменяет выполняющиеся запросы при поступлении нового запроса.
- Отображает выпадающий список с навигацией с клавиатуры.
- Отслеживает событие
search_queryдля аналитики.
Полный компонент
"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>
);
}Почему useDeferredValue вместо debounce через setTimeout
useDeferredValue позволяет React немедленно отрендерить поле ввода и отложить зависимый запрос до тех пор, пока пользователь не перестанет печатать. Он хорошо сочетается с конкурентным рендерингом — в отличие от таймера debounce. Для React 18+ используйте этот подход.
Для не-React или React < 18 используйте небольшой хук debounce:
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;
}Отмена запросов
AbortController жёстко контролирует устаревшие запросы — без него медленный первый запрос может завершиться после быстрого второго и перезаписать выпадающий список старыми данными. SDK передаёт signal в нижележащий fetch.
Подсветка
Добавление highlightFields: "title" возвращает совпавшие подстроки, обёрнутые в теги <mark>, внутри hit.highlights.title.snippet. Для отображения:
<a
key={p.id}
href={`/products/${p.id}`}
dangerouslySetInnerHTML={{
__html: hit.highlights?.title?.snippet ?? p.title,
}}
/>dangerouslySetInnerHTML здесь безопасен, потому что AACsearch санирует подсвеченный вывод (генерируются только теги <mark>). Не используйте его с непроверенным текстом из других источников.
Отслеживание запроса
Для настройки релевантности отправляйте запрос как событие search_query после получения результатов:
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
<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>Связанные страницы
Рецепты
Готовые "копировать-вставить" рецепты для самых частых паттернов AACsearch SDK — автокомплит, фасетный поиск, листинги товаров, трекинг кликов, scoped токены, мульти-аренда, мульти-локали и graceful failure.
Фасетный поиск
Боковая панель с множественным выбором фильтров (бренд, категория, диапазон цен) на основе фасетных подсчётов AACsearch. Фильтры комбинируются через И; подсчёты обновляются реактивно.