Реалізація автодоповнення пошуку для веб-програм
Автодоповнення — поле пошуку яке показує підказки по мірі введення. Технічно це перетин кількох завдань: швидкий fuzzy-пошук по індексу, debounce щоб не перевантажити сервер, правильна робота з клавіатурою (ARIA combobox), скасування застарілих запитів. Кожне розв'язується окремо; разом вони становлять повнофункціональний компонент.
Архітектура пошуку
Client-side — дані завантажуються разом, пошук у браузері. Підходить до ~50 000 записів. Бібліотеки: Fuse.js (fuzzy), MiniSearch (fulltext). Без затримки після завантаження.
Server-side — кожне введення — запит до сервера. Потрібен debounce та скасування запитів. Підходить для великих даних. Elasticsearch suggest API, Typesense, PostgreSQL trgm.
Гібридний — кешуємо популярні запити в пам'яті, рідкі йдуть на сервер.
Базовий хук з debounce та скасуванням
import { useState, useEffect, useRef, useCallback } from 'react';
type SearchResult = {
id: string;
title: string;
category?: string;
url: string;
};
function useAutocomplete(
fetchFn: (query: string, signal: AbortSignal) => Promise<SearchResult[]>,
delay = 250
) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const abortRef = useRef<AbortController | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const search = useCallback((value: string) => {
setQuery(value);
if (timerRef.current) clearTimeout(timerRef.current);
if (abortRef.current) abortRef.current.abort();
if (value.trim().length < 2) {
setResults([]);
return;
}
timerRef.current = setTimeout(async () => {
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
setError(null);
try {
const data = await fetchFn(value, controller.signal);
if (!controller.signal.aborted) {
setResults(data);
}
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}, delay);
}, [fetchFn, delay]);
useEffect(() => () => {
if (timerRef.current) clearTimeout(timerRef.current);
if (abortRef.current) abortRef.current.abort();
}, []);
return { query, results, loading, error, search };
}
Компонент з ARIA Combobox
Правильна реалізація за ARIA паттерном combobox:
import { useId, useRef, useState } from 'react';
interface AutocompleteProps {
placeholder?: string;
onSelect: (result: SearchResult) => void;
fetchResults: (query: string, signal: AbortSignal) => Promise<SearchResult[]>;
}
export function Autocomplete({ placeholder, onSelect, fetchResults }: AutocompleteProps) {
const id = useId();
const listId = `${id}-listbox`;
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const { query, results, loading, search } = useAutocomplete(fetchResults);
const [activeIndex, setActiveIndex] = useState(-1);
const [open, setOpen] = useState(false);
const isOpen = open && (results.length > 0 || loading);
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, -1));
break;
case 'Enter':
if (activeIndex >= 0 && results[activeIndex]) {
onSelect(results[activeIndex]);
setOpen(false);
setActiveIndex(-1);
}
break;
case 'Escape':
setOpen(false);
setActiveIndex(-1);
inputRef.current?.focus();
break;
}
};
return (
<div className="autocomplete" role="combobox" aria-expanded={isOpen} aria-haspopup="listbox">
<input
ref={inputRef}
type="search"
placeholder={placeholder}
value={query}
aria-autocomplete="list"
aria-controls={listId}
aria-activedescendant={activeIndex >= 0 ? `${id}-option-${activeIndex}` : undefined}
onChange={(e) => {
search(e.target.value);
setOpen(true);
setActiveIndex(-1);
}}
onFocus={() => query.length >= 2 && setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
onKeyDown={handleKeyDown}
/>
{isOpen && (
<ul
ref={listRef}
id={listId}
role="listbox"
className="autocomplete__dropdown"
>
{loading && (
<li role="option" aria-selected="false" className="autocomplete__loading">
Пошук...
</li>
)}
{results.map((result, index) => (
<li
key={result.id}
id={`${id}-option-${index}`}
role="option"
aria-selected={index === activeIndex}
className={`autocomplete__option ${index === activeIndex ? 'autocomplete__option--active' : ''}`}
onMouseDown={() => {
onSelect(result);
setOpen(false);
}}
onMouseEnter={() => setActiveIndex(index)}
>
<HighlightMatch text={result.title} query={query} />
{result.category && (
<span className="autocomplete__category">{result.category}</span>
)}
</li>
))}
</ul>
)}
</div>
);
}
Підсвітлення збігів
function HighlightMatch({ text, query }: { text: string; query: string }) {
if (!query.trim()) return <span>{text}</span>;
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const parts = text.split(new RegExp(`(${escaped})`, 'gi'));
return (
<span>
{parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase()
? <mark key={i}>{part}</mark>
: <span key={i}>{part}</span>
)}
</span>
);
}
Серверна частина: Elasticsearch suggest
// POST /api/suggest
async function suggestHandler(req: Request) {
const { q } = await req.json();
if (!q || q.length < 2) return Response.json({ hits: [] });
const response = await esClient.search({
index: 'products',
body: {
suggest: {
title_suggest: {
prefix: q,
completion: {
field: 'title.suggest', // поле типу completion
size: 10,
fuzzy: { fuzziness: 'AUTO' },
},
},
},
// Додатково — fulltext пошук для більш релевантних результатів
query: {
multi_match: {
query: q,
fields: ['title^3', 'description', 'tags^2'],
type: 'bool_prefix',
},
},
_source: ['id', 'title', 'category', 'url', 'image'],
size: 10,
},
});
return Response.json({
hits: response.hits.hits.map((h) => h._source),
});
}
Індекс з полем completion:
{
"mappings": {
"properties": {
"title": {
"type": "text",
"fields": {
"suggest": {
"type": "completion",
"analyzer": "standard"
},
"keyword": { "type": "keyword" }
}
}
}
}
}
PostgreSQL з pg_trgm
Для проектів без Elasticsearch:
-- Розширення для триграмного пошуку
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Індекс
CREATE INDEX products_title_trgm_idx ON products
USING gin (title gin_trgm_ops);
-- Запит з підказками
SELECT id, title, category, similarity(title, $1) AS score
FROM products
WHERE title % $1 -- триграмний збіг
OR title ILIKE $1 || '%' -- starts-with (швидше для точних збігів)
ORDER BY
title ILIKE $1 || '%' DESC, -- starts-with пріоритет
score DESC
LIMIT 10;
Кешування на стороні клієнта
const cache = new Map<string, { data: SearchResult[]; ts: number }>();
const TTL = 30_000; // 30 секунд
async function fetchWithCache(query: string, signal: AbortSignal) {
const cached = cache.get(query);
if (cached && Date.now() - cached.ts < TTL) {
return cached.data;
}
const res = await fetch(`/api/suggest?q=${encodeURIComponent(query)}`, { signal });
const data = await res.json();
cache.set(query, { data: data.hits, ts: Date.now() });
if (cache.size > 100) {
// Видаляємо найстарішу запис
cache.delete(cache.keys().next().value);
}
return data.hits;
}
Часові рамки
- Просте автодоповнення (fetch + debounce, без ARIA, без підсвітлення): 4–8 годин
- Повнофункціональний компонент (ARIA combobox, keyboard navigation, підсвітлення, client cache): 2–3 дні
- З серверним індексом Elasticsearch (completion mapping, налаштування індексу, fuzzy): ще 1–2 дні







