Запустите запрос в useEffect, указав в массиве зависимостей входные данные запроса, сохраните результат в стейт и отрисуйте три состояния: загрузку, ошибку и данные. Флаг в cleanup (или AbortController) не даёт старому ответу перезаписать более новый.
Загрузка: true до первого ответа; покажите скелетон или спиннер.
Ошибка: fetch не реджектит промис на 404 или 500, поэтому проверяйте res.ok сами и бросайте ошибку.
Данные: отрисуйте их; не забудьте и пустой случай (пустой список не ошибка).
Зависимости: userId в массиве перезапрашивает данные при его смене; без него вы покажете устаревшие данные прошлого пользователя.
На практике: для обучения и маленьких приложений этого хватает; в продакшене обычно используют TanStack Query или loader фреймворка, потому что у этого паттерна нет кэша, дедупликации и повторных попыток.
ReactMiddle
Загрузка данных
Запросы в эффектах и их ловушки, TanStack Query, кэш и инвалидация, Suspense для данных, пагинация, оптимистичные обновления, ошибки
Закрыто вопросов
0 / 21
- 0Повторить
- 7Часто спрашивают
Вопросы
TanStack Query (раньше React Query) представляет собой кэш для серверного состояния: вы описываете запрос через queryKey и queryFn, а он берёт на себя состояния загрузки и ошибки, кэширование, дедупликацию запросов, фоновый перезапрос, повторные попытки и инвалидацию после мутаций.
Кэш по ключу: два компонента, которые просят ['user', 7], делят один запрос и один закэшированный результат.
Stale-while-revalidate: данные из кэша показываются сразу, а если они устарели, в фоне идёт перезапрос.
Повторные попытки: упавшие запросы по умолчанию повторяются 3 раза с экспоненциальной задержкой.
Мутации: useMutation вместе с invalidateQueries держит списки актуальными после записи.
Не глобальный стор: он для данных, которыми владеет сервер; UI-состояние (открытая модалка, черновик формы) остаётся в стейте React или в клиентском сторе.
fetch отклоняет промис, только когда ответ не пришёл совсем (сбой сети, блокировка CORS, abort); 404 или 500 всё равно являются корректным HTTP-ответом, поэтому промис выполняется с res.ok === false. Axios отклоняет промис при любом статусе вне 2xx, сам разбирает JSON и добавляет интерсепторы и таймаут.
**Проверяйте res.ok** (статус от 200 до 299) после каждого fetch и бросайте ошибку сами, иначе страница с ошибкой разберётся как данные, а TanStack Query сочтёт сбой успехом.
JSON: fetch требует await res.json(), а для тела запроса JSON.stringify плюс заголовок Content-Type; axios делает и то и другое.
Интерсепторы: у axios есть интерсепторы запросов и ответов (заголовки авторизации, обновление токена, логирование); с fetch это пишут в небольшую обёртку, через которую идут все вызовы.
Таймауты и отмена: оба принимают AbortSignal, а AbortSignal.timeout(10_000) даёт fetch таймаут.
Зависимость: fetch встроен в браузеры и Node 18+; axios добавляет зависимость, зато сообщает прогресс загрузки на сервер (onUploadProgress), а fetch нет.
Эффекты запускаются после рендера, только на клиенте и по разу на каждый смонтированный компонент, поэтому у самописной загрузки появляются гонки, отсутствие кэша, дублирующиеся запросы и водопады запросов. Каждую проблему можно решить вручную, но это ровно тот код, который уже есть в библиотеке данных или loader фреймворка.
Гонки (race conditions): при быстром вводе уходит несколько запросов, и медленный ранний ответ может прийти последним; нужен флаг ignore или AbortController.
Нет кэша: при возврате на страницу данные загружаются заново и снова виден спиннер; два компонента с одинаковыми данными делают два запроса.
Водопады: родитель загружает данные, рендерит дочерний компонент, и только тогда тот начинает свой запрос; задержки складываются.
Поздний старт: запрос начинается после загрузки JavaScript и первого рендера, а не во время загрузки страницы; SSR не может его выполнить.
StrictMode: в режиме разработки эффект запускается дважды, поэтому вы видите два запроса; это проверка правильности cleanup, а не баг, который надо подавлять.
Шаблонный код: логика загрузки, ошибок, повторов и перезапроса при фокусе повторяется в каждом компоненте.
Query key служит адресом в кэше: одинаковый ключ означает одни и те же закэшированные данные. Кладите в ключ все входные данные queryFn (id, фильтры, страницу) от общего к частному, чтобы разные входные данные попадали в разные записи кэша, а инвалидация могла задеть целую ветку.
Иерархия: ['todos'], ['todos', 'list', { status }], ['todos', 'detail', id]; тогда invalidateQueries({ queryKey: ['todos'] }) обновит их все по префиксу.
Объекты допустимы: ключи хэшируются детерминированно, поэтому { status, page } совпадает независимо от порядка свойств.
Автоматический перезапрос: когда переменная в ключе меняется, запрос переключается на новую запись кэша и загружает её; массив зависимостей не нужен.
Фабрики ключей: держите ключи в одном модуле вместе с queryOptions, чтобы ключ и его queryFn не разошлись и переиспользовались в useQuery, prefetchQuery и invalidateQueries.
ESLint: в @tanstack/eslint-plugin-query есть правило exhaustive-deps, которое ловит переменную, используемую в queryFn, но отсутствующую в ключе.
page используется в queryFn, но отсутствует в queryKey, поэтому запись в кэше одна для всех страниц: TanStack Query видит неизменный ключ, оставляет закэшированную первую страницу и не делает перезапрос. Добавьте page в ключ.
Когда перезапрашивать, решает ключ, а не массив зависимостей; замыкание с новым page не вызывается для ключа, данные которого ещё свежие.
С page в ключе у каждой страницы своя запись в кэше, поэтому возврат на первую страницу происходит мгновенно.
Добавьте placeholderData: keepPreviousData, чтобы показывать предыдущую страницу, пока грузится следующая, вместо спиннера; isPlaceholderData подсказывает, что на экране старая страница.
Правило линтера @tanstack/query/exhaustive-deps ловит именно эту ошибку.
staleTime задаёт, сколько загруженные данные считаются свежими: пока они свежие, они отдаются из кэша без перезапроса. gcTime задаёт, сколько неиспользуемый запрос (на который не подписан ни один компонент) хранится в памяти, прежде чем его удалят. По умолчанию staleTime равен 0, gcTime равен 5 минутам.
**staleTime: 0: данные сразу становятся устаревшими, поэтому каждый новый маунт, фокус окна или переподключение запускает фоновый перезапрос, а закэшированные данные всё равно показываются мгновенно.
Увеличивайте staleTime** для редко меняющихся данных (список стран: Infinity; профиль: несколько минут); это главный рычаг, чтобы сократить число запросов.
**gcTime начинает отсчёт только после размонтирования последнего подписчика; если вернуться раньше, кэш покажется мгновенно вместо спиннера.
Название**: до v5 gcTime назывался cacheTime; его путали со staleTime, отсюда и переименование.
SSR: ставьте staleTime больше 0, чтобы клиент не перезапрашивал данные, которые сервер только что отрендерил.
isPending означает, что данных ещё нет (status === 'pending'); isFetching означает, что запрос выполняется прямо сейчас, включая фоновые перезапросы уже показанных данных; isLoading означает и то и другое сразу, то есть самую первую загрузку.
Спиннер или скелетон на всю страницу: isPending, потому что рисовать нечего.
Маленький индикатор обновления: isFetching && !isPending, данные на экране и обновляются.
Выключенные запросы: запрос с enabled: false и без кэша навсегда остаётся в isPending, поэтому и существует isLoading: он false, когда ничего не загружается.
История: в v4 isLoading означал то, что сейчас означает isPending; в v5 статус loading переименовали в pending.
Ошибки: проверяйте isError или error отдельно; при неудачном фоновом перезапросе старые data остаются, и можно показать и то и другое.
Устаревший запрос перезапрашивается в фоне, когда монтируется новый компонент, который его использует, когда окно снова получает фокус и когда восстанавливается сеть; с refetchInterval он ещё и опрашивает сервер. Всё это срабатывает только для устаревших данных, поэтому настраивать в первую очередь нужно staleTime.
**refetchOnMount, refetchOnWindowFocus, refetchOnReconnect**: по умолчанию все true; задайте false или 'always' для отдельного запроса или в defaultOptions.
Сюрпризы с фокусом окна: переключение вкладок в разработке вызывает запросы; это работает фича, а не баг. Лучше увеличить staleTime, чем отключать её везде.
Polling: refetchInterval: 5000 для живых данных; refetchIntervalInBackground продолжает опрос в скрытых вкладках (по умолчанию выключен).
Вручную: refetch() из хука или queryClient.invalidateQueries(), чтобы пометить данные устаревшими и перезапросить всё, что на экране.
React Native: фокуса окна там нет, поэтому focusManager и onlineManager подключают к AppState и NetInfo.
Передайте результат первого запроса в ключ второго и выключите второй, пока этого значения нет: через enabled: !!userId или, с сохранением типов, queryFn: userId ? () => fetchProjects(userId) : skipToken. Когда значение появится, ключ изменится, и второй запрос стартует.
**skipToken** (v5): тогда TypeScript знает, что userId внутри queryFn определён, и ! не нужен; enabled типы не сужает.
Статус: выключенный запрос находится в isPending с fetchStatus: 'idle'; если нужно именно «идёт загрузка», используйте isLoading.
Цена: зависимые запросы по определению образуют водопад; если сервер может вернуть оба результата одним запросом или второму первый на самом деле не нужен, загружайте их параллельно.
Несколько независимых запросов: useQueries выполняет динамический список запросов параллельно.
useMutation({ mutationFn }) выполняет запись и отслеживает её isPending, error и data; сам по себе он кэш не трогает. После успеха либо инвалидируйте затронутые запросы, чтобы они перезапросились, либо запишите ответ сервера прямо в кэш через setQueryData.
**invalidateQueries**: простой и надёжный вариант по умолчанию; он помечает подходящие запросы устаревшими и перезапрашивает те, что на экране. Верните его промис из onSuccess, чтобы мутация оставалась pending, пока не придут свежие данные.
**setQueryData: экономит запрос, если в ответе уже есть обновлённая сущность; обновите детальную запись и поправьте список.
mutate и mutateAsync**: mutate обрабатывает ошибки через колбэки; mutateAsync возвращает промис, который нужно ловить через catch самому.
Колбэки: onSuccess, onError и onSettled у useMutation выполняются, даже если компонент размонтирован; колбэки, переданные в mutate(), нет.
Мутация инвалидирует ['projects', 42, 'todos'] с числом из ответа сервера, а список закэширован под ['projects', '42', 'todos'] со строкой из URL. Ключи запросов сравниваются по значению и типу, поэтому ничего не инвалидируется и список не перезапрашивается.
invalidateQueries сравнивает фильтр с каждым ключом в кэше поэлементно; '42' и 42 различаются, а фильтр, который ничему не соответствует, молча ничего не делает.
staleTime в пять минут прячет баг ещё глубже: без инвалидации закэшированный список считается свежим, поэтому ни повторное монтирование, ни возврат фокуса в окно его не перезапрашивают. Новая задача появится только после перезагрузки (или когда пройдут пять минут).
Исправление: собирайте все ключи в одном месте (фабрика ключей или queryOptions), типизированном так, чтобы projectId всегда был строкой, и инвалидируйте ровно тем значением, которое использовал запрос.
String(todo.projectId) тоже сработал бы, но он повторяет форму ключа во втором месте, а именно так расхождение и появилось.
Как заметить: TanStack Query Devtools показывают ключ в кэше с "42" в кавычках, по-прежнему свежий после мутации.
В onMutate отмените текущие запросы по этому ключу, сохраните снимок кэша, запишите ожидаемый результат через setQueryData и верните снимок; в onError положите снимок обратно; в onSettled сделайте инвалидацию, чтобы в любом случае кэш в итоге совпал с сервером.
**Зачем cancelQueries: перезапрос, начатый до мутации, может прийти после неё и перезаписать оптимистичное значение старыми данными.
Контекст**: то, что вернул onMutate, приходит третьим аргументом в onError и onSettled.
Вариант попроще: не трогайте кэш и рисуйте mutation.variables как ожидающую строку, пока isPending; при ошибке она просто исчезнет, и откатывать ничего не нужно. Подходит, когда изменение показывает только один компонент.
Когда не стоит: платежи, необратимые действия или записи, которые часто падают; неверный оптимистичный UI там хуже короткого ожидания.
React 19: useOptimistic делает то же самое для состояния, которым владеет React, а не кэш запросов.
Используйте useInfiniteQuery: задайте initialPageParam, queryFn, который читает pageParam, и getNextPageParam, который возвращает следующий курсор из последней страницы или undefined, если страниц больше нет. Он хранит все загруженные страницы в data.pages и даёт fetchNextPage, hasNextPage и isFetchingNextPage.
Курсор вместо offset: курсор от сервера (nextCursor) не пропускает и не дублирует элементы, если во время скролла добавляются новые строки.
Триггер: вызывайте fetchNextPage(), когда элемент-маркер внизу становится видимым (IntersectionObserver), с проверкой hasNextPage && !isFetchingNextPage.
Цена перезапроса: при перезапросе все загруженные страницы загружаются заново по порядку, чтобы курсоры оставались согласованными; maxPages ограничивает число хранимых страниц.
Длинные списки: сочетайте с виртуализацией (TanStack Virtual), чтобы тысячи строк не оставались в DOM.
Обычная пагинация с номерами страниц делается обычным useQuery со страницей в ключе и placeholderData: keepPreviousData.
Для каждой ошибки решите, где ей место: неудачная первая загрузка заменяет контент (сообщение на месте или error boundary), неудачный фоновый перезапрос оставляет старые данные с небольшим уведомлением, а неудачная мутация показывается рядом с действием. TanStack Query повторяет запрос 3 раза с экспоненциальной задержкой, прежде чем сообщить об ошибке.
Повторы: retry и retryDelay настраиваются для каждого запроса; отключайте повторы для ответов 4xx, которые сами не исправятся (retry: (count, err) => ...). Мутации по умолчанию не повторяются.
Error boundaries: throwOnError: true (или useSuspenseQuery, который всегда бросает ошибку, если данных нет) отправляет ошибку в ближайший boundary; QueryErrorResetBoundary позволяет кнопке «Попробовать снова» перезапросить данные.
Фоновые ошибки: data и error могут быть заданы одновременно; показывайте данные с подсказкой «не удалось обновить», а не очищайте экран.
Глобальные отчёты: QueryCache({ onError }) логирует или показывает тост для каждой ошибки запроса один раз, а не в каждом компоненте.
**Ловушка fetch**: он реджектит только при сетевой ошибке; проверяйте res.ok, иначе каждый 500 станет «успехом» с телом ошибки.
Передайте AbortSignal в fetch и вызовите abort, когда результат больше не нужен. В useEffect создайте AbortController и вызывайте abort() в cleanup; TanStack Query сам передаёт signal в ваш queryFn и отменяет его, когда запрос размонтирован или его ключ изменился.
Cleanup эффекта: выполняется при смене зависимостей и при размонтировании, ровно в те моменты, когда старый запрос становится бесполезным.
Игнорируйте ошибку отмены: отменённый fetch реджектится с AbortError (DOMException); не показывайте её как сбой.
TanStack Query: если queryFn не использует signal, запрос всё равно завершится, и результат попадёт в кэш; с ним устаревший запрос действительно отменяется.
Axios: тоже принимает signal в конфиге.
Экономия трафика: отмена останавливает загрузку и освобождает соединение; работу, которую сервер уже начал, она не отменяет.
Пропускайте каждый 401 через один общий промис обновления: первый упавший запрос запускает обновление, остальные ждут тот же промис, а затем каждый повторяется один раз с новым токеном. Если упало само обновление, разлогиньте пользователя, а не повторяйте запросы.
Почему обновление одно на всех
Пять параллельных запросов, каждый со своим обновлением, отправят пять запросов на refresh. При ротации refresh-токенов первый вызов делает старый refresh-токен недействительным, остальные четыре предъявляют уже использованный, а сервер с обнаружением повторного использования (reuse detection) считает это кражей и отзывает всю сессию.
Промис очищается в finally, поэтому следующее истечение запускает новое обновление.
Важные детали
Повтор один раз: флаг isRetry не даёт уйти в бесконечный цикл, если сервер продолжает отвечать 401.
Поздние 401: запрос, отправленный со старым токеном, может упасть уже после того, как обновление завершилось; сравните токен, с которым он ушёл, с текущим и просто повторите запрос, если токен уже сменился.
Сам запрос на refresh никогда не повторяют: 401 от него означает, что сессия закончилась.
TanStack Query: исключите 401 из retry, чтобы запрос не повторялся ещё и поверх слоя fetch.
Вокруг этого
Обновление незадолго до exp убирает большинство 401, но этот путь всё равно нужен на случай расхождения часов и отозванных токенов.
Вкладки делят одну refresh-куку и могут обгонять друг друга; Web Locks API (navigator.locks.request) позволяет обновлять токен только одной вкладке за раз.
В axios та же логика живёт в интерсепторе ответа, с флагом повтора в конфиге запроса.
Компонент, которому нужны ещё не загруженные данные, приостанавливается (suspend), и ближайший <Suspense> показывает свой fallback, пока данные не придут; сам компонент рендерит только успешный случай. Приостанавливаются через источник, который поддерживает Suspense: use(promise), useSuspenseQuery или loader фреймворка.
Декларативная загрузка: расположение boundary определяет, что показывает скелетон вместе; ошибки уходят в error boundary уровнем выше.
**useSuspenseQuery**: data всегда определён, ветки isPending нет; enabled и skipToken не поддерживаются, поэтому рендерите компонент, только когда его входные данные уже есть.
**use(promise): промис должен быть стабильным между рендерами (закэширован библиотекой или передан из Server Component), а не создаваться во время рендера.
Транзишены**: если смена ключа происходит внутри startTransition (или через useDeferredValue), React продолжает показывать старый контент, а не мигает fallback снова.
Параллельные соседи: два приостанавливающихся запроса под одним boundary стартуют вместе, только если они соседи; если один вложен в другой, он ждёт, и получается водопад. useSuspenseQueries загружает несколько запросов сразу.
Промис создаётся во время рендера, поэтому каждый рендер передаёт в use совершенно новый промис: React приостанавливается, запрос завершается, React рендерит заново, стартует новый fetch, и компонент снова приостанавливается. Промисы для use должны кэшироваться вне рендера: библиотекой, фреймворком или Server Component.
React предупреждает об этом в консоли: «A component was suspended by an uncached promise».
При первом монтировании между такими попытками ничего не сохраняется: приостановленный рендер выбрасывает свой стейт и кэш useMemo, поэтому useMemo в том же компоненте не помогает.
Исправления: useSuspenseQuery (TanStack Query кэширует промис по ключу), промис, созданный родителем или Server Component и переданный пропом, или небольшой кэш на уровне модуля с ключом id.
В Server Component то же самое пишется просто как const user = await fetchUser(id).
Водопад представляет собой цепочку запросов, где каждый начинается только после завершения предыдущего; обычно компонент загружает данные, затем рендерит дочерний, который начинает свой запрос. Три запроса по 200 мс в цепочке занимают 600 мс вместо 200. Лечится это тем, что запросы стартуют как можно раньше и параллельно.
Откуда они берутся
Загрузка при рендере (fetch-on-render): каждый уровень дерева ждёт данных родителя, прежде чем вообще смонтироваться.
Ленивые роуты: скачать код роута, затем отрендерить его, и только затем начать запрос данных.
Вложенные Suspense boundary, где внутренний приостанавливающийся компонент рендерится только после того, как разрешился внешний.
Как их избежать
Поднимите выше: загружайте данные на уровне роута (loader в React Router, Server Components в Next.js), чтобы все запросы стартовали вместе с навигацией, до рендера компонентов.
Распараллельте: Promise.all, useQueries или useSuspenseQueries для независимых данных; соседи, а не родитель и потомок.
Префетч: queryClient.prefetchQuery при наведении или в loader, а затем useQuery читает уже прогретый кэш.
Измените API: если второму запросу действительно нужен результат первого, один эндпоинт или join на сервере убирает лишний сетевой круг.
Как их увидеть
Колонка waterfall в панели Network браузера или TanStack Query Devtools; ступенчатый узор ни с чем не спутать.
Создайте QueryClient на каждый запрос на сервере, выполните await queryClient.prefetchQuery(...) для того, что нужно странице, и передайте dehydrate(queryClient) в <HydrationBoundary>; клиентские компоненты ниже вызывают тот же useQuery и находят данные уже в кэше, без состояния загрузки и без второго запроса.
Один клиент на запрос на сервере, никогда не синглтон на уровне модуля, иначе данные одного пользователя утекут на страницу другого.
На клиенте создавайте QueryClient один раз (в useState или как синглтон модуля в браузере) внутри провайдера с 'use client'.
**staleTime больше 0: со значением по умолчанию 0 клиент перезапросит данные сразу после гидратации, удваивая каждый запрос.
Одинаковые опции**: делите queryOptions между серверным префетчем и клиентским хуком, чтобы ключи совпадали в точности.
Стриминг: v5 умеет дегидратировать и незавершённые запросы, так что страница стримится, пока медленные запросы догружаются.
Когда не нужно: если Server Component может сам отрендерить данные и ничего на клиенте их не перезапрашивает и не мутирует, обойдитесь без кэша и просто сделайте там await.