Загрузка данных и гонки запросов
«Пользователь быстро печатает в поиске, а на экране оказываются результаты не от последнего запроса. Найдите баг» — вопрос, на котором сыплются даже уверенные мидлы.
Гонка запросов (race condition) — ситуация, когда несколько асинхронных запросов завершаются в непредсказуемом порядке и в состояние последним записывается ответ на уже неактуальный запрос.
Код, который приносят на собеседование
function SearchResults({ query }) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
fetch(`/api/search?q=${query}`)
.then((r) => r.json())
.then((data) => {
setItems(data.items); // кто ответил последним — тот и записал
setLoading(false);
});
}, [query]);
return loading ? <Spinner /> : <List items={items} />;
}
Код выглядит безупречно: зависимость [query] на месте, загрузка отображается. Но пользователь печатает «react»: на пути к этому слову компонент успевает отправить запросы за «re», «rea», «reac» и «react». Сеть не обещает, что ответы придут в том же порядке, в каком ушли запросы. Если ответ на «re» задержался в очереди на бэкенде и вернулся после ответа на «react», в состояние последним попадёт именно он. На экране — результаты для «re», в поле ввода — «react».
Гонка своими глазами
Никакого React здесь не нужно — суть в порядке промисов. Запустите пример: «медленный» запрос ушёл первым, «быстрый» — вторым, но выигрывает всё равно тот, кто записал последним.
function fakeFetch(query, delayMs) {
return new Promise((resolve) => {
setTimeout(() => resolve('результаты для ' + query), delayMs);
});
}
let screen = 'пусто'; // это наш useState
async function search(query, delayMs) {
const data = await fakeFetch(query, delayMs);
screen = data; // setItems(...)
console.log('setState:', data);
}
async function main() {
search('re', 300); // ушёл первым, вернётся последним
search('react', 50); // ушёл вторым, вернётся первым
await new Promise((r) => setTimeout(r, 400));
console.log('На экране:', screen);
}
main();
Результат:
setState: результаты для react
setState: результаты для re
На экране: результаты для re
Пользователь видит устаревшие данные, и никакой ошибки в консоли при этом нет — самый неприятный класс багов.
Решение 1: пометить устаревший ответ ненужным
У функции очистки эффекта есть ровно то свойство, которое нам нужно: React вызывает её перед каждым повторным запуском эффекта и при размонтировании. Значит, в ней можно «закрыть» предыдущий запрос.
useEffect(() => {
let ignore = false; // замыкание своё у каждого запуска эффекта
fetch(`/api/search?q=${query}`)
.then((r) => r.json())
.then((data) => {
if (!ignore) setItems(data.items); // ответ протухшего запроса просто выбрасываем
});
return () => { ignore = true; }; // сработает при смене query и при размонтировании
}, [query]);
Приём официально рекомендован документацией React и решает саму гонку. Но запрос при этом всё равно долетает до сервера и качает данные, которые мы выбросим.
Решение 2: AbortController — отменить по-настоящему
useEffect(() => {
const controller = new AbortController();
let ignore = false;
async function load() {
setLoading(true);
try {
const res = await fetch(`/api/search?q=${query}`, { signal: controller.signal });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
if (!ignore) {
setItems(data.items);
setLoading(false);
}
} catch (err) {
if (ignore || err.name === 'AbortError') return; // это не ошибка, это наша же отмена
setError(err);
setLoading(false);
}
}
load();
return () => {
ignore = true;
controller.abort(); // браузер реально рвёт соединение
};
}, [query]);
Здесь спрятана деталь, за которую на собеседовании дают отдельный плюс. Соблазнительно написать finally { setLoading(false); } — но finally выполняется и после return из catch. Порядок событий при смене query такой: React вызывает очистку старого эффекта (abort()) → синхронно запускает новый эффект (setLoading(true)) → и только потом в микрозадаче отклоняется отменённый fetch. Его finally поставит loading = false — спиннер погаснет, хотя новый запрос ещё в пути. Поэтому loading сбрасываем только на ветках, которые точно относятся к текущему запросу.
Про StrictMode
В режиме разработки React 18+ монтирует компонент дважды: эффект выполняется, очищается и выполняется снова. Кандидаты пугаются двух запросов в Network и пытаются «отключить StrictMode». Правильный ответ: StrictMode нарочно вскрывает эффекты без очистки. Если код написан как выше, второй запуск безобиден — первый запрос будет отменён.
Как это работает под капотом
Порядок ответов не гарантирован ничем: параллельные HTTP-запросы идут разными соединениями, время ответа зависит от нагрузки на бэкенд, кеша и балансировщика. А setItems подчиняется правилу «последний записавший выигрывает» — React не знает и не может знать, какой из ответов актуален. Отсюда вывод: гонку нельзя «починить» на уровне React, её можно только явно разрешить в коде, отбросив или отменив устаревший ответ.
controller.abort() переводит signal в состояние aborted, браузер прекращает загрузку, а промис fetch отклоняется с DOMException, у которого name === 'AbortError'. Отличать эту «ошибку» от настоящей обязательно — иначе при каждом переключении фильтра пользователь будет видеть красное «Не удалось загрузить данные».
Почему библиотеки данных существуют
Вопрос на собеседовании почти всегда заканчивается так: «И что, писать это в каждом компоненте?» Правильный ответ — нет, и вот полный список того, чего наивный useEffect + fetch не умеет: кеш по ключу, дедупликация одинаковых запросов, автоматическая отмена устаревших, повторные попытки при сбое сети, показ старых данных, пока грузятся новые (stale-while-revalidate), инвалидация после мутации, пагинация, обновление при возврате фокуса на вкладку. Каждый из этих пунктов вы рано или поздно напишете сами — просто хуже и в пятнадцати местах.
// то же самое на TanStack Query: гонка, кеш и отмена — из коробки
const { data, isLoading, error } = useQuery({
queryKey: ['search', query], // сменился query — это уже другой запрос и другая ячейка кеша
queryFn: ({ signal }) =>
fetch(`/api/search?q=${query}`, { signal }).then((r) => r.json()),
});
Ключ кеша ['search', query] — сердце подхода: библиотека сама понимает, что данные для «re» и для «react» — разные сущности, и никогда их не перепутает. Отмену она делает тем же signal, который вы теперь умеете писать руками.
Частые ошибки на собеседовании
- «Проверю флаг
isMounted». Это лечит другой симптом (обновление размонтированного компонента) и вообще не спасает от гонки между двумя живыми запросами. - «Поставлю debounce». Debounce уменьшает число запросов, но два оставшихся всё так же могут вернуться не по порядку.
- Пустой массив зависимостей
[]при меняющемсяquery— данные вообще перестают обновляться. - Не отличают
AbortErrorот настоящей ошибки и показывают экран ошибки при обычном переключении вкладки. setLoading(false)вfinally— спиннер гаснет из-за отменённого запроса.- Хранят серверные данные в Context «как глобальный стор» — получают ручной кеш без инвалидации и те же гонки, только этажом выше.
Итоги-шпаргалка
- Гонка возникает всегда, когда запрос зависит от меняющегося параметра: ответы приходят в произвольном порядке, побеждает последний записавший.
- Минимальное лечение — флаг
ignore, который выставляется в функции очистки эффекта. - Правильное лечение —
AbortController:controller.abort()в очистке,err.name === 'AbortError'вcatch. - Очистка эффекта вызывается перед каждым его повторным запуском и при размонтировании — это штатное место, чтобы закрыть предыдущий запрос.
- Не сбрасывайте
loadingвfinally: отменённый запрос погасит спиннер актуального. - StrictMode дважды запускает эффект в dev нарочно — корректный эффект с очисткой это переживает.
- TanStack Query, SWR, RTK Query существуют ровно потому, что кеш, дедупликация, отмена и ретраи нужны в каждом проекте.