Menu

Загрузка данных в React через useEffect: ошибки и гонки

Загружайте данные в React, запуская запрос в useEffect, храня загрузку, ошибку и данные в состоянии и игнорируя ответы, которые пришли после изменения входных данных. async/await в эффектах, AbortController, состояния гонки и когда лучше взять библиотеку.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

Чтобы загрузить данные в 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), устанавливайте его перед началом запроса и сбрасывайте по завершении, и при успехе, и при ошибке. Пока оно истинно, рендерите индикатор.

Иллюстрация языков программирования Coddy

Учитесь программировать с Coddy

НАЧАТЬ