Чтобы загрузить данные в React, запустите запрос внутри useEffect, сохраните ответ в состоянии и рендерите сообщение о загрузке, пока он не придёт. Поместите значения, от которых зависит запрос, например id, в массив зависимостей, чтобы эффект загружал данные снова при их изменении.
Примеры на этой странице не имеют доступа к сети, поэтому fetchUser это поддельный API: промис, который выполняется после задержки, как это делает fetch. Замените 800 на 3000, чтобы текст загрузки держался дольше.
С настоящим API тело эффекта выглядит так:
useEffect(() => {
fetch('https://api.example.com/users/1')
.then((res) => res.json())
.then((data) => setUser(data));
}, []);
Загрузка, ошибка и данные
У запроса три исхода, которые должен показывать экран: ещё загружается, не удался или готов. Храните каждый в состоянии и сбрасывайте их, когда эффект начинает новый запрос.
Нажмите User 3: поддельный API отклоняет запрос, сообщение об ошибке заменяет имя, а консоль показывает ошибку. Блок finally сбрасывает loading на обоих путях, поэтому неудачный запрос никогда не оставляет индикатор включённым.
Когда fetch общается с настоящим сервером, ответ 404 или 500 не отклоняет промис. Проверяйте res.ok и выбрасывайте ошибку сами:
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
async/await внутри эффекта
Функция, которую вы передаёте в useEffect, не может быть async. React ожидает, что она вернёт ничего или функцию очистки, а асинхронная функция всегда возвращает промис.
// Wrong: the effect returns a promise
useEffect(async () => {
const data = await fetchUser(id);
setUser(data);
}, [id]);
// Right: define an async function inside and call it
useEffect(() => {
async function load() {
const data = await fetchUser(id);
setUser(data);
}
load();
}, [id]);
Состояния гонки
Когда id быстро меняется, одновременно выполняются два запроса. Ничто не гарантирует, что они ответят по порядку. Если старый медленнее, он придёт последним и перезапишет данные для id, который пользователь выбрал позже всех.
Решение это флаг ignore. У каждого запуска эффекта свой флаг, и его очистка устанавливает его в true. Поскольку React выполняет очистку перед следующим запуском, ответ от устаревшего запуска видит ignore === true и отбрасывается. Этот блок рендерит один и тот же профиль дважды, без флага и с ним:
Нажмите кнопку и подождите. Быстрый ответ для пользователя 2 приходит первым, поэтому оба профиля выводят сообщение и оставляют Grace. Примерно через полторы секунды после клика приходит медленный ответ для пользователя 1: профиль без флага переключается на Ada, хотя выбран пользователь 2, а профиль с флагом выводит dropped stale Ada и оставляет Grace.
Та же ошибка возникает в полях поиска, вкладках и любом списке, который фильтрует сервер. Пример с загрузкой выше обходится без флага ради краткости, но у каждой загрузки в эффекте он должен быть, либо отмена, описанная ниже.
Отмена через AbortController
Флаг ignore отбрасывает устаревший ответ, но запрос всё равно выполняется до конца. С настоящим fetch его можно отменить. Создайте AbortController в эффекте, передайте его signal в fetch и вызовите abort() в очистке:
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const res = await fetch(`/api/users/${id}`, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setUser(await res.json());
} catch (err) {
if (err.name === 'AbortError') return; // cancelled on purpose
setError(err);
}
}
load();
return () => controller.abort();
}, [id]);
Отменённый fetch отклоняется с AbortError, поэтому блок catch пропускает его, а не показывает как сбой. Отмена также покрывает размонтирование: когда компонент удаляется, его очистка отменяет запрос.
Перенос в пользовательский хук
Загрузка, ошибка и защита от гонок одинаковы в каждом компоненте, который загружает данные. Вынесите их в пользовательский хук, и каждый компонент будет запрашивать данные одной строкой.
fetchPosts объявлена вне компонента, поэтому её идентичность никогда не меняется, и она безопасна в массиве зависимостей. Объявленная внутри App, она была бы новой функцией при каждом рендере, эффект запускался бы после каждого рендера, а поскольку эффект устанавливает состояние, загрузка никогда бы не прекратилась.
Замените 500 на 2000 и нажмите кнопку два раза подряд: консоль покажет два запроса, и появятся только посты по той теме, на которой вы остановились.
Загрузка в обработчике события
Эффект нужен для данных, которые нужны компоненту потому, что он на экране: страница профиля загружает свой профиль. Когда запрос происходит потому, что пользователь что-то сделал, например нажал Search или Save, делайте его в обработчике события. Вы точно знаете, что его вызвало, и ничего не перезапускается при изменении несвязанного состояния.
async function handleSubmit(e) {
e.preventDefault();
setStatus('saving');
const res = await fetch('/api/notes', { method: 'POST', body: JSON.stringify({ text }) });
setStatus(res.ok ? 'saved' : 'error');
}
Частые ошибки
Пропущенные зависимости. Эффект, который читает id, но имеет [], вечно загружает первого пользователя. Перечисляйте каждое значение, которое использует запрос.
Загрузка в теле компонента. fetch вне эффекта выполняется при каждом рендере, а если он устанавливает состояние, запускается цикл.
Надежда, что fetch отклонится при ошибках. Он отклоняется, только когда отказывает сеть. Проверяйте res.ok.
Забытый сброс загрузки. Когда id меняется, верните loading в true, иначе старые данные останутся на экране без всякого признака того, что идут новые.
Когда использовать библиотеку или фреймворк
Загрузка в эффекте подходит для нескольких запросов. Она не кэширует: откройте один и тот же профиль дважды, и он загрузится дважды. Она не делит данные между компонентами, не повторяет запрос при сбое и не перезагружает данные, когда вкладка снова получает фокус. Библиотеки делают это за вас:
import { useQuery } from '@tanstack/react-query';
function Profile({ id }) {
const { data, error, isPending } = useQuery({
queryKey: ['user', id],
queryFn: () => fetch(`/api/users/${id}`).then((res) => res.json()),
});
if (isPending) return <p>Loading...</p>;
if (error) return <p>{error.message}</p>;
return <p>{data.name}</p>;
}
TanStack Query и SWR это обычный выбор для данных на клиенте. Фреймворки идут дальше и загружают данные на сервере ещё до того, как страница попадёт в браузер: Next.js с серверными компонентами, React Router с загрузчиками (loaders). Это избавляет от мелькания загрузки и от цепочки запросов, которая возникает, когда данные родителя должны прийти до того, как дочерний компонент сможет хотя бы начать загрузку.
React 19: use() с Suspense
React 19 добавляет use, который читает промис во время рендера. Компонент приостанавливается, пока промис не выполнится, а ближайшая граница <Suspense> тем временем показывает запасной вариант, поэтому у самого компонента нет состояния загрузки:
import { use, Suspense } from 'react';
function Profile({ userPromise }) {
const user = use(userPromise);
return <p>{user.name}</p>;
}
<Suspense fallback={<p>Loading...</p>}>
<Profile userPromise={userPromise} />
</Suspense>
Промис должен создаваться вне компонента (фреймворком, кэшем или родителем), а не внутри рендера, который его читает, иначе каждый рендер запускает новый запрос. Страница о хуке use разбирает это на запускаемых примерах.
Часто задаваемые вопросы
Как загрузить данные, когда загружается компонент React?
Запустите запрос в useEffect, указав в массиве зависимостей значения, от которых он зависит ([], если таких нет), и сохраните результат в состоянии через useState. Пока данные не пришли, рендерите сообщение о загрузке.
Почему колбэк useEffect не может быть async?
Асинхронная функция всегда возвращает промис, а React ожидает, что эффект вернёт либо ничего, либо функцию очистки. Напишите асинхронную функцию внутри эффекта и сразу её вызовите.
Что такое состояние гонки при загрузке данных в React?
Когда входные данные быстро меняются, одновременно выполняются два запроса, и старый может ответить последним, перезаписав новые данные. Устанавливайте флаг ignore в очистке эффекта и пропускайте setState, когда он установлен, или отменяйте запрос через AbortController.
Загружать данные через useEffect или библиотекой?
useEffect подходит для небольших приложений, и его стоит понимать. Для кэширования, дедупликации, повторных попыток и перезагрузки библиотека вроде TanStack Query или SWR либо загрузка данных, встроенная во фреймворк вроде Next.js, избавляет от необходимости писать эту логику самостоятельно.
Как показать индикатор загрузки, пока данные загружаются?
Храните в состоянии булево loading (или строку status), устанавливайте его перед началом запроса и сбрасывайте по завершении, и при успехе, и при ошибке. Пока оно истинно, рендерите индикатор.