Почему функции-колбэки React useEffect никогда не должны быть асинхронными функциями
Асинхронная функция, передаваемая в useEffect, нарушает правила очистки в React, поскольку возвращает Promise. Четыре правильных подхода к выполнению асинхронных операций внутри эффектов.
Источники: React Interview Question Asked in Paytm
Этот вопрос возник во время собеседования на позицию фронтенд-разработчика в Paytm, и он заслуживает большего признания, чем может показаться из-за своей простоты. На первый взгляд кажется, что речь идет о незначительной синтаксической особенности. На самом деле он проверяет, понимаете ли вы цикл жизни эффектов, как на самом деле работают JavaScript Promises, что React делает внутри с тем, что возвращает ваш эффект, и как правильно загружать данные внутри компонента.
Большинство кандидатов дают ответ, который технически верен, но касается лишь поверхностных аспектов:
«useEffect ожидает, что вы вернете функцию для очистки, тогда как асинхронная функция возвращает Promise.»
Это разумная вступительная фраза, но она не отвечает на интересные вопросы. Что делает React с этим Promise после его получения? Просто выбрасывает его? Что на самом деле ломается в результате? И, что ещё более важно, как следует структурировать асинхронную логику внутри effect, чтобы ничего не сломалось?
Давайте постепенно составим полную картину, проверяя каждое утверждение на соответствие документации React и самому исходному коду React.
Часть 1: То, чего ожидает функция useEffect
Функцию, передаваемую в useEffect, официально называют функцией настройки, и документация React строго регламентирует, что она может возвращать. Существует только два допустимых результата:
- Возврат ничего, то есть значение равно
undefined, или - Возврат функции очистки, которую React вызовет позже.
useEffect(() => {
// setup: runs after the component is committed to the screen
const id = setInterval(tick, 1000);
// cleanup: React calls this before the next setup run,
// and one final time when the component unmounts
return () => clearInterval(id);
}, []);
Функция очистки — это не просто удобная дополнительность. Это механизм, от которого зависит React для уничтожения всего того, что было создано на этапе настройки: будь то таймер, подписка, обработчик событий или открытое соединение WebSocket. Согласно документации React, функция очистки вызывается сразу перед повторным выполнением эффекта, а также ещё раз при окончательной демонтажке компонента.
Вот что делает всю эту проблему значимой: React нуждается в функции очистки немедленно, сразу после завершения выполнения функции настройки. Иногда очистка должна произойти практически мгновенно, возможно, даже до того, как выполнится первый оператор await в асинхронной функции. Функция очистки, которая появляется «позже», не может быть использована React.
Часть 2: Почему асинхронность всегда означает Promise
Этот раздел связан с обычным поведением JavaScript, и здесь нет никаких исключений. Согласно MDN, функции, объявленные с ключевым словом async, всегда возвращают объект Promise, точка. Даже если вы возвращаете что-то, что ещё не является объектом Promise, JavaScript тихо оборачивает это значение в такой объект для вас. Синтаксис, позволяющий обойти это правило, отсутствует.
async function getCleanup() {
return () => console.log('cleaning up');
}
const result = getCleanup();console.log(result);
// Promise { <fulfilled>: f } <- the function is trapped insideconsole.log(typeof result);
// "object" <- not "function"
Внимательно посмотрите на то, что произошло. В коде явно возвращается функция-стрелка, но вызывающий код всё равно не получает функцию. Вместо этого он получает объект Promise, который в конечном итоге разрешится до этой функции. Это верно даже для асинхронной функции без любых операторов возврата — она всё равно возвращает объект Promise, который разрешится до значения undefined.
Свяжите эти два момента воедино:
- React ожидает либо значение
undefined, либо реальную функцию, переданную синхронно.
Таким образом, условия контракта уже нарушаются до того, как у логики действий появляется возможность выполниться.
// What you think you wrote:
useEffect(async () => {
const data = await fetchUser(id);
setUser(data);
return () => controller.abort(); // "my cleanup"
}, [id]);
// What React receives from your setup function:
// Promise { <pending> }
// Your cleanup is locked inside a box React never opens.
Часть 3: Что на самом деле делает React с возвращаемым значением
Именно здесь многие объяснения идут не тем путем. Часто говорят, что «React просто игнорирует возвращаемый Promise». Однако исходный код говорит о другом: React на самом деле выполняет три отдельных действия с этим возвращаемым значением, причем два из них вызывают реальные проблемы.
3.1 Он хранит Promise в качестве операции очистки
В глубине компонента reconciler, а именно в функции commitHookEffectListMount — ответственной за выполнение эффектов после сохранения изменений — React вызывает вашу функцию setup и хранит её возвращаемое значение напрямую, без каких-либо проверок. В режиме производственной сборки не проводится проверка во время выполнения, чтобы выявить несоответствие типов:
// react-reconciler/src/ReactFiberCommitEffects.js (simplified)
const create = effect.create; // your setup function
const destroy = create(); // whatever it returns...
inst.destroy = destroy; // ...is stored as the cleanup
Если переданная вами функция setup была асинхронной, то inst.destroy теперь содержит задерживаемый Promise в том месте, где React ожидает функцию очистки, способную к вызову.
3.2 Режим разработки выдаёт очень конкретное предупреждение
После сохранения значения во время сборки приложения выполняется дополнительный шаг проверки. React проверяет, равно ли typeof destroy !== 'function', и делает это не просто через общую проверку типа — он конкретно ищет метод .then в возвращаемом значении. Это явный признак того, что вы передали вместо функции объект Promise, и React прямо об этом сообщает:
useEffect must not return anything besides a function, which is used for
clean-up.
It looks like you wrote useEffect(async () => ...) or returned a Promise.
Instead, write the async function inside your effect and call it immediately:useEffect(() => {
async function fetchData() {
// You can await here
const response = await MyAPI.getData(someId);
// ...
}
fetchData();
}, [someId]); // Or [] if effect doesn't need props or stateLearn more about data fetching with Hooks: https://react.dev/link/hooks-data-fetching
Обратите внимание, что предупреждение не просто указывает на проблему — оно также содержит решение в самом сообщении, показывая пример объявления асинхронной функции внутри эффекта и её немедленного вызова. Когда собственный текст ошибки фреймворка уже содержит решение, это обычно явный сигнал того, что интервьюеры ожидают, что вы с этим сталкивались ранее.
3.3 Когда наступает время очистки, React пытается вызвать Promise
Именно здесь ситуация становится по-настоящему опасной. Когда меняются зависимости эффекта или компонент демонтируется, логика демонтажа React проверяет лишь наличие данных для очистки — она никогда не проверяет, является ли сохранённое значение вызываемым функцией:
// commitHookEffectListUnmount (simplified)
const destroy = inst.destroy;
if (destroy !== undefined) {
safelyCallDestroy(effect, ...); // executes destroy()
}
// destroy is a Promise here, and a Promise is not callable:
// TypeError: destroy is not a function
Попытка вызвать Promise как функцию приводит к ошибке TypeError. Именно это вызвало известную ошибку «Uncaught TypeError: destroy is not a function» в React 17. В текущих версиях React такой вызов оборачивается в защитный механизм, поэтому вместо сбоя всей страницы ошибка фиксируется на этапе выполнения коммита и передаётся ближайшему границе обработки ошибок. В любом случае это настоящая ошибка во время выполнения, а не просто сообщение в консоли, которое можно проигнорировать.
В сумме возникают три отдельные ошибки:
- Ваш реальный код очистки никогда не выполняется, поэтому таймеры, обработчики событий и подписки также никогда не удаляются.
- Во время разработки React выводит предупреждение.
- Позже, когда React пытается выполнить операцию очистки, он пытается вызвать Promise как функцию и получает ошибку.
Асинхронная функция настройки не обходит правила незаметно — она нарушает их в двух разных моментах жизненного цикла компонента.
Часть 4: Пошаговое сравнение двух путей выполнения
Полезно сравнивать синхронную и асинхронную функции настройки шаг за шагом. Первые три шага у обеих функций идентичны. Различия появляются начиная с четвертого шага.
Синхронная версия:
- Выполняется функция компонента, и вычисляется JSX.
- React сохраняет изменения, после чего браузер отрисовывает содержимое.
- React вызывает вашу функцию настройки.
cleanup().Асинхронная версия:
- Выполняется функция компонента, и рассчитывается JSX.
- React сохраняет изменения, после чего браузер отрисовывает содержимое.
- React вызывает вашу функцию настройки, которая, будучи асинхронной, мгновенно возвращает результат.
- Возвращается объект
Promise { <pending> }, и React хранит этот Promise как будто это функция очистки. В режиме разработки здесь появляется предупреждение. - Ваши вызовы
awaitпо-прежнему обрабатываются в фоновом режиме, и любая логика очистки, которую вы написали, находится внутри этого нереализованного Promise и не доступна для выполнения. - Когда меняются зависимости или компонент демонтируется, React пытается вызвать сохраненное значение — которое является не функцией — и выдает ошибку
TypeError. - Логика очистки так и не была выполнена. Интервалы продолжают работать, а устаревшие ответы могут всё ещё вызывать
setState.
Это сравнение также объясняет, почему контракт требует синхронного выполнения. Эффекты активируются после коммита, когда рендеринг уже завершён, и процедура очистки предыдущего эффекта должна выполняться непосредственно перед активацией следующего эффекта — что может произойти сразу при следующем рендеринге. Если бы React должен был ждать выполнения Promise, чтобы выяснить наличие функции очистки, вся последовательность коммитов пришлось бы останавливаться из-за произвольного кода пользователя, что противоречит замыслу работы фазы коммитов. Вот почему API был разработан с учётом синхронных возвратов, а не с целью научить React ожидать выполнения Promise.
Часть 5: Условие гонки, скрытое этим паттерном
Существует ещё одна, более скрытая опасность асинхронного подхода: без функции очистки у вас нет способа отменить или проигнорировать устаревшие асинхронные операции. Собственная документация React иллюстрирует это на примере страницы с профилями, и её важно хорошо понимать, потому что речь идёт не о стилистической детали — это баг, который можно отправить в продакшн.
Вот как это происходит:
- Вы нажимаете на «Bob», и приложение начинает загружать его биографию.
- Прежде чем это закончится, вы нажимаете на «Taylor», что запускает загрузку его биографии.
- Ответ от Taylor приходит первым, поэтому интерфейс отображает его биографию.
- Ответ от Bob, который пришёл позже из-за медленнее работы, перезаписывает текущее состояние.
- В итоге на экране отображается Taylor как выбранный пользователь, но под ним — биография Bob.
Поскольку ответы сети не обязательно приходят в том порядке, в котором были отправлены запросы, такое несоответствие представляет реальный риск. Стандартное решение — функция очистки, которая устанавливает флаг ignore, позволяя игнорировать все ответы, кроме того, который связан с последней отрисовкой. Это решение работает только в том случае, если функция настройки возвращает результат синхронно, предоставляя React реальную функцию обратного вызова для очистки.
Следовательно, асинхронный обратный вызов — это не просто техническое нарушение условий API: он устраняет сам механизм, который предотвращает появление этой ситуации гонки при выполнении кода у пользователей.
Часть 6: Правильные паттерны
Каждое действительно эффективное решение основано на одной идее: сделать саму функцию настройки синхронной и перенести асинхронную работу в то, что эта функция вызывает. Таким образом, React всё равно получает реальную функцию обратного вызова для очистки.
Шаблон 1: Имянная асинхронная функция внутри эффекта (официальная рекомендация)
Этот подход показан в документации React, и он включает флаг ignore, который устраняет описанную выше ситуацию с конкурентным доступом:
useEffect(() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
} startFetching(); return () => {
ignore = true;
};
}, [userId]);
Внешняя функция-стрелка никогда не использует await, поэтому return () => { ignore = true; } предоставляет React настоящую функцию очистки, которую можно сохранить. Внутренняя именная функция же может свободно использовать await для выполнения необходимых действий.
Шаблон 2: Асинхронная IIFE
Этот подход выполняет ту же задачу, но с использованием анонимной функции, которая запускается сразу, вместо именной:
useEffect(() => {
let ignore = false;
(async () => {
const data = await fetchUser(id);
if (!ignore) setUser(data);
})(); return () => { ignore = true; };
}, [id]);
Шаблон 3: Простые цепочки .then()
В собственном примере загрузки данных документация React на самом деле использует именно этот стиль:
useEffect(() => {
let ignore = false;
setBio(null);
fetchBio(person).then(result => {
if (!ignore) {
setBio(result);
}
});
return () => {
ignore = true;
};
}, [person]);
Шаблон 4: AbortController — когда нужна реальная отмена
Флаг ignore предотвращает замену состояния устаревшим ответом, но не останавливает сам запрос от выполнения в фоновом режиме до конца. При использовании fetch можно пойти дальше и полностью отменить сетевой запрос. В документации React прямо указано, что процедура очистки должна либо прервать запрос, либо проигнорировать его результат:
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const res = await fetch(`/api/users/${id}`, {
signal: controller.signal,
});
setUser(await res.json());
} catch (e) {
if (e.name !== 'AbortError') throw e;
}
})(); return () => controller.abort();
}, [id]);
С чего следует начать при обсуждении этих вопросов на собеседовании? Начните с Паттерна 1 — именно на него указывают как предупреждения в режиме разработки React, так и официальная документация. Прямо укажите флаг ignore, а затем добавьте, что для запросов fetch следует пойти дальше и использовать AbortController, чтобы отменить запрос вместо простого игнорирования его результата. Эта комбинация — корректность, проблема соревновательных условий и ненужный трафик в сети — помещается в три предложения и охватывает всю суть проблемы.
Часть 7: StrictMode и TypeScript также помогают с этим
StrictMode. Во время разработки StrictMode намеренно выполняет дополнительный цикл настройки, а затем очистки сразу после первоначальной загрузки. Согласно документации React, это делается для тестирования на прочность: проверяется, действительно ли логика очистки полностью соответствует логике настройки. Асинхронный эффект по своей природе не может пройти эту проверку, поскольку никогда не возвращает пригодную для использования функцию очистки. Именно это принудительное двойное вызовение позволяет обнаружить дублированные подписки, дублирующиеся интервалы и повторные запросы, результаты обоих из которых в конечном итоге записываются в состояние.
TypeScript. Определения типов для React встраивают этот контракт непосредственно в систему типов:
// From @types/react (simplified)
type Destructor = () => void;
type EffectCallback = () => void | Destructor;
function useEffect(effect: EffectCallback, deps?: DependencyList): void;
Асинхронная функция имеет сигнатуру () => Promise<void>, и этот тип просто не может быть присвоен переменной типа EffectCallback. Поэтому в проекте на TypeScript такая ошибка даже не доходит до этапа компиляции. Это само по себе заслуживает внимания: ограничение настолько фундаментально, что оно реализуется в трех разных местах — в системе типов, в предупреждениях режима разработки и в фактическом поведении при выполнении.
Часть 8: Современные альтернативы, стоит упомянуть
Более полный ответ также указывает на то, что ручной запрос данных внутри эффектов больше не считается лучшей практикой. Сами документы React отмечают, что прямой запрос данных внутри эффектов делает код избыточным и затрудняет последующее добавление функций кэширования и серверной отрисовки. Вместо этого они предлагают несколько альтернатив:
- Загрузчики на уровне фреймворка. Если проект использует такой фреймворк, как Next.js или Remix, его встроенный механизм получения данных предпочтительнее клиентского решения.
- Библиотеки кэширования на стороне клиента. В документации React конкретно упоминаются TanStack Query, useSWR и React Router 6.4+. Эти инструменты уже обрабатывают удаление дубликатов, кэширование и вышеописанные ситуации конкуренции.
use, введённая в React 19. Функция use(promise) позволяет компоненту напрямую получать значение, уже реализованное в Promise, приостанавливая работу компонента до завершения обработки Promise и используя механизм Suspense для отображения запасного контента. Стоит упомянуть один важный нюанс: Promise, созданный заново во время отрисовки, генерируется заново при каждой отрисовке, поэтому use предназначена для использования Promise, переданных из серверных компонентов или управляемых кэшем, а не для прямого использования результатов вызова fetch() внутри кода компонента.Ничто из этого не заменяет основное объяснение. Вопрос о контракте эффекта на самом деле проверяет, понимаете ли вы, что React делает внутри с возвращаемыми значениями. Однако заключение вроде «и именно поэтому для получения реальных данных следует использовать TanStack Query или загрузчик фреймворков» показывает, что вы также знаете, каковы современные лучшие практики.
Ответ на интервью за 30 секунд
Если вы должны запомнить только одну версию этого ответа, пусть это будет именно она:
Сам обратный вызов является синхронным: необходимо определить внутри него асинхронную функцию, вызвать эту функцию и вернуть механизм очистки, который либо устанавливает флаг игнорирования в значение true, либо запускает метод abort в объекте AbortController. Для получения данных в реальном времени лучше использовать загрузчик фреймворков или библиотеку вроде TanStack Query, поскольку они автоматически обрабатывают подобные ситуации конкуренции.«Функция настройки useEffect соблюдает синхронный контракт: она должна возвращать либо ничего, либо функцию очистки, поскольку React нуждается в том, чтобы эта функция была готова к вызову непосредственно перед повторным выполнением эффекта и каждый раз, когда компонент демонтируется. Асинхронная функция никогда не может соответствовать этому требованию, потому что асинхронные функции всегда возвращают Promise, независимо от того, что происходит внутри них. React затем сохраняет этот Promise в том же месте, где должна находиться функция очистки: в режиме разработки он выводит предупреждение «Похоже, вы написали useEffect(async () => …)», реальная логика очистки так и не получает возможности выполниться, а позже, когда React пытается вызвать сохраненное значение как функцию очистки, возникает ошибка TypeError. На практике это приводит к утечкам подписок и ситуациям гонки, поскольку без рабочей функции очистки невозможно игнорировать или прерывать устаревшие запросы. Решение заключается в том, чтобы сохранять»
В этом ответе рассматриваются условия работы, основные правила JavaScript, конкретные способы сбоев, проблема конкуренции, приемлемые решения и тенденции современной экосистемы. Это показывает интервьюеру, что концепция не была просто заучена как правило — понимается механизм, стоящий за ней, что именно и предполагается выяснить с помощью такого вопроса.