Загрузка данных и гонки запросов

«Пользователь быстро печатает в поиске, а на экране оказываются результаты не от последнего запроса. Найдите баг» — вопрос, на котором сыплются даже уверенные мидлы.

Гонка запросов (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 существуют ровно потому, что кеш, дедупликация, отмена и ретраи нужны в каждом проекте.
Проверьте себя
1. Почему useEffect + fetch с зависимостью [query] порождает гонку запросов?
AReact выполняет эффекты в непредсказуемом порядке
BОтветы на запросы приходят в произвольном порядке, и setState устаревшего ответа перезаписывает уже показанные свежие данные
Cfetch кеширует ответы в браузере и отдаёт старые
DuseState обновляется асинхронно и теряет промежуточные значения
2. Что делает возвращаемая из эффекта функция return () => { ignore = true; controller.abort(); }?
AОстанавливает рендер компонента до получения ответа
BВыполняется перед следующим запуском эффекта и при размонтировании: помечает старый ответ ненужным и реально отменяет запрос
CОтменяет запрос только при размонтировании компонента, при смене query не срабатывает
DЗаменяет собой массив зависимостей эффекта