Автодоповнення в React без застарілих результатів
Надійне автодоповнення в React — це не лише debounce: поєднайте затримку запиту, скасування, захист від застарілих відповідей і чіткі стани.
Користувач вводить «ка» у пошук товарів, а потім швидко додає «мера». Інтерфейс показує результати для «ка» вже після появи новіших результатів — або замінює їх за мить. Для користувача пошук працює ненадійно. У мережевій панелі обидва запити завершилися успішно. Проблема в тому, що відповіді можуть надходити не в тому порядку, у якому було надіслано запити.
Debounce зменшує кількість запитів, але не гарантує правильних результатів
Debounce чекає, доки користувач припинить вводити текст, перш ніж надіслати запит. Це допомагає не надсилати запит після кожного натискання клавіші, але не заважає старішому запиту завершитися пізніше. Якщо запит для «ка» триває 500 мс, а для «камера» — 120 мс, стара відповідь може замінити нову. Така сама гонка виникає, коли користувач очищує поле, змінює фільтри або переходить на іншу сторінку, поки запит ще виконується.
Надійне автодоповнення має окремо враховувати три речі: коли починається запит, чи можна зупинити застарілу роботу та чи залишається відповідь актуальною, коли вона надходить. Якщо звести все до проблеми «debounce», легко пропустити важливі випадки.
Визначте життєвий цикл запиту для кожного пошукового запиту
Практичний життєвий цикл простий: після зміни вводу трохи зачекати, скасувати попередній таймер і запит, коли змінюється текст, і застосувати відповідь лише тоді, коли вона стосується останнього запиту. Таймер і запит потребують окремого очищення: попередній запит може ще чекати на запуск, коли надходить наступний.
useEffect(() => {
const normalized = query.trim();
if (!normalized) {
setResults([]);
setLoading(false);
return;
}
const controller = new AbortController();
const requestId = ++latestRequest.current;
const timer = window.setTimeout(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(normalized)}`, { signal: controller.signal });
if (!response.ok) throw new Error(`Search failed: ${response.status}`);
const data: SearchResponse = await response.json();
if (requestId === latestRequest.current) setResults(data.items);
} catch (error) {
if (!controller.signal.aborted && requestId === latestRequest.current) setError(error);
} finally {
if (requestId === latestRequest.current) setLoading(false);
}
}, 250);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [query]);Цей ефект передбачає, що `latestRequest` — це ref, `SearchResponse` описує відповідь API, а компонент уже керує станами `query`, `results`, `loading` і `error`. Затримка 250 мс — лише приклад, а не універсальне значення: підбирайте її з огляду на швидкість взаємодії в продукті та затримку API. Нормалізуйте введення перед запитом, але зберігайте відображуваний текст окремо, якщо продукт має показувати саме те, що ввів користувач.
Скасування та захист від застарілих відповідей розв’язують різні задачі
`AbortController` просить браузер зупинити попередній fetch і роботу з тілом відповіді. Це може заощадити ресурси на клієнті й зменшити зайву обробку, але не гарантує, що сервер припинить обробляти запит, який уже отримав. Так само коректність не має залежати від того, чи скасування випередить відповідь, яка вже завершується.
Ідентифікатор запиту забезпечує перевірку коректності: лише останній запит може оновлювати результати, помилки або стан завантаження. Це важливо, бо застаріла робота може вплинути не тільки на список. Наприклад, блок `finally` старого запиту може вимкнути індикатор завантаження, поки найновіший запит іще очікує. Перевірка скасування в обробнику помилки також не дає показувати очікуване скасування як помилку для користувача.
Визначте, що бачить користувач між запитами
Життєвий цикл даних — лише частина взаємодії. Вирішіть, чи залишати попередні результати видимими під час пошуку, одразу їх очищувати чи показувати ненав’язливий індикатор завантаження. Кожен варіант обіцяє користувачу щось своє. Попередні результати можуть зробити інтерфейс стабільнішим, але позначайте їх або візуально відрізняйте, якщо користувач може сприйняти їх як відповідність новому запиту.
- Порожнє поле: очистіть результати й поверніть інтерфейс до початкового стану.
- Надходить новий запит: чітко покажіть завантаження; не створюйте враження, що старі результати відповідають новому запиту.
- Успішна відповідь без збігів: покажіть виразний порожній стан, а не порожню панель.
- Помилка запиту: покажіть можливість відновлення та зрозумілу дію для повторної спроби.
- Скасовано застарілий запит: не показуйте повідомлення; під час введення це нормальна ситуація.
Також подумайте про мінімальну довжину запиту й окремий сценарій підтвердження пошуку. Це правила продукту, а не хитрощі реалізації: запит з одного символу може бути надто широким для великого каталогу, водночас користувачі клавіатури й допоміжних технологій мають розуміти, як працювати з підказками. Якщо можна вибирати підказку, розрізняйте фокус клавіатури, активну підказку та підтверджене значення.
Відокремте логіку запитів від рендерингу
Коли пошук охоплює права доступу, фільтри, кешування або кілька кінцевих точок, розміщувати всю логіку запитів у компоненті стає складніше: її важче тестувати й повторно використовувати. Винесіть API-клієнт окремо, а компонент залиште відповідальним за взаємодію та відображення. Такий поділ корисний не лише для автодоповнення; дивіться Як винести API-клієнти з React-компонентів. Якщо результати спільні для кількох екранів або мутації змінюють дані, визначте, як оновлювати кеш замість того, щоб додавати ситуативні запити; суміжну тему розглянуто у статті Інвалідація кешу React Query: припиніть повторно завантажувати все.
Тестуйте порядок відповідей, а не лише успішний сценарій
Тест, у якому користувач вводить запит і отримує одну відповідь, не виявить гонки. Надішліть два запити, спочатку поверніть новішу відповідь, а потім — старішу. Переконайтеся, що нові результати залишилися на екрані. Також перевірте, що очищення поля скасовує таймер, скасований запит не показує помилку, а старіший запит не може вимкнути індикатор завантаження для новішого. Такі тести захищають поведінку, яку помічають користувачі, а не конкретну реалізацію хука.
Для невеликого поля без спільних даних може вистачити локального ефекту. Якщо пошук керує складним застосунком, використовуйте шар запитів, коли кешування, усунення дубльованих запитів, повторні спроби або узгодження між екранами справді потрібні. Не додавайте цю інфраструктуру лише заради затримки запиту на кілька мілісекунд.
Будуйте взаємодію навколо чітких гарантій
Надійне автодоповнення не створюється одним лише debounce. Debounce керує часом запиту; скасування обмежує зайву роботу на клієнті; перевірка останнього запиту не дає оновити інтерфейс застарілими даними; продумані стани зрозуміло показують, що відбувається. Разом ці рішення охоплюють і мережеву гонку, і те, як її сприймає користувач.
Якщо автодоповнення — частина ширшого сценарію пошуку, облікового запису чи продукту з великою кількістю даних, ці самі деталі пов’язані із загальною архітектурою застосунку. WebMriya розробляє складні вебзастосунки та продуктові інтерфейси, у яких асинхронна взаємодія має залишатися передбачуваною в міру розвитку функцій.
Поширені запитання
Чи запобігає debounce появі застарілих результатів?
Ні. Debounce відкладає запуск запиту, але відповіді все одно можуть надходити не за порядком. Щоб застарілі відповіді не оновлювали інтерфейс, використовуйте скасування та перевірку останнього запиту.
Чи достатньо AbortController для запитів автодоповнення?
Він допомагає зупинити fetch на клієнті та обробку тіла відповіді, але не має бути єдиним механізмом захисту коректності. Ідентифікатор запиту або аналогічна перевірка гарантує, що інтерфейс оновить лише найновіша відповідь.
Якою має бути затримка debounce в автодоповненні?
Універсального значення немає. Виберіть затримку, що балансує між швидкою взаємодією та зайвими запитами, а потім перевірте її з огляду на реальну швидкість введення й затримку API.