AACsearch
SDKРецепты

Автодополнение

Поиск с задержкой по мере ввода и выпадающим списком подсказок. Использует `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>

Связанные страницы

On this page