Чому калеби React useEffect ніколи не повинні бути асинхронними функціями
Асинхронна функція, яка передається до useEffect, порушує умови очищення в React, оскільки повертає Promise. Чотири правильні підходи для виконання асинхронних операцій всередині effects.
Це питання виникло під час співбесіди з фронтенд-розробником у 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: Чому async завжди означає Promise
Цей розділ стосується звичайної поведінки JavaScript, і тут немає жодних винятків. Згідно з MDN, функції, оголошені за допомогою async, завжди повертають Promise, крапка. Навіть якщо ви повертаєте щось, що ще не є Promise, JavaScript тихо обгортає цей об’єкт у Promise за вас. Не існує синтаксису, який дозволив би уникнути цього.
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, який зрештою резолвується до цієї функції. Це справедливо навіть для async-функції без жодної оператора return — вона все одно повертає 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 цей виклик обгорнутий у захищений механізм, тож замість того, щоб вивести з ладу всю сторінку, помилка фіксується на етапі виконання та передається до найближчого межі обробки помилок. У будь-якому разі це справжня проблема під час виконання, а не просто повідомлення у консолі, яке можна проігнорувати.
У підсумку ми маємо три окремі проблеми:
- Ваш справжній код очищення ніколи не виконується, тому таймери, слухачі подій та підписки також ніколи не скасовуються.
- Pendant розробки React записує попередження.
- Пізніше, коли React намагається виконати процедуру „очищення“, він намагається викликати Promise як функцію та отримує помилку.
Функція налаштування асинхронного типу не проходить непомітно повз правила — вона порушує їх у двох окремих моментах життєвого циклу компонента.
Частина 4: Порівняння двох шляхів виконання крок за кроком
Корисно порівнювати синхронну та асинхронну функції налаштування крок за кроком. Перші три кроки у них збігаються повністю. Усе починає відрізнятися з четвертого кроку.
Синхронна версія:
- Виконується функція компонента, і обчислюється JSX.
- React зберігає зміни, а браузер відображає контент.
- React викликає вашу функцію налаштування.
cleanup().Асинхронна версія:
- Виконується функція компонента та обчислюється JSX.
- React фіксує оновлення, і браузер відображає зміни.
- React викликає вашу функцію налаштування, яка, будучи асинхронною, миттєво повертає результат.
- Повертається
Promise { <pending> }, і React зберігає цей Promise, ніби це функція очищення. У режимі розробки тут виникає попередження. - Ваші виклики
awaitвсе ще обробляються у фоновому режимі, а будь-яка логіка очищення, яку ви написали, знаходиться всередині цього нерозрешеного Promise та є недоступною. - Коли змінюються залежності або компонент демонтують, React намагається викликати збережене значення — яке є не функцією — і викидає помилку
TypeError. - Очищення так і не відбулося. Інтервали продовжують працювати, а застарілі відповіді все ще можуть ініціювати виклик
setState.
Це порівняння також пояснює, чому у контракті вимагається синхронний підхід. Ефекти активуються після виконання commit, коли фарбування вже відбулося, а очищення попереднього ефекту має виконатися безпосередньо перед активацією наступного ефекту — що може статися вже під час наступної обробки. Якби React мусив чекати на Promise, щоб з’ясувати, чи існує функція очищення, вся послідовність commit мусила б зупинятися через довільний код користувача, що суперечить принципам роботи фази commit. Ось справжня причина, чому 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 чітко сказано, що процедура очищення має або припинити виконання fetch, або проігнорувати його результат:
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 призначена для використання обіцянок, переданих з серверних компонентів або керованих кешем, — а не для прямого використання функції fetch(), написаної безпосередньо в коді.Ніщо з цього не може замінити основне пояснення. Питання про контракт ефекту насправді перевіряє, чи розумієте ви, що React робить усередині з повернутою вами значенням. Але завершення на кшталт «і саме тому для отримання справжніх даних варто скористатися TanStack Query або іншим фреймворком-лодером» свідчить про те, що ви також знаєте, які насправді є сучасні найкращі практики.
Відповідь на інтерв’ю за 30 секунд
Якщо ви будете пам’ятати лише одну версію цієї відповіді, то нехай це буде саме ця:
Сам калебек є синхронним; потрібно визначити всередині нього асинхронну функцію, викликати цю функцію та повернути функцію для очищення, яка або встановлює прапорець ignore у значення true, або запускає метод abort у об’єкті AbortController. Для отримання даних у реальному світі кращим вибором є лоадер фреймворку чи бібліотека на кшталт TanStack Query, оскільки вони вже автоматично керують цими ситуаціями конкуренції.„Функція налаштування useEffect дотримується синхронної конвенції: вона повинна повертати або нічого, або функцію для очищення, оскільки React потребує, щоб ця функція була готова до виклику безпосередньо перед повторним виконанням ефекту та щоразу, коли компонент демонтовується. Асинхронна функція ніколи не може відповідати цій вимозі, адже вона завжди повертає Promise, незалежно від того, що виконується всередині неї. React потім зберігає цей Promise у тому самому місці, де має знаходитися функція для очищення: під час розробки він виводить попередження ‘Схоже, ви написали useEffect(async () => …)’, справжня логіка очищення так і не має можливості виконатися, а пізніше, коли React намагається викликати збережене значення, як ніби це функція для очищення, виникає помилка TypeError. На практиці це проявляється у витоках підписок та ситуаціях змагання, оскільки без функції для очищення немає способу проігнорувати або скасувати застарілий запит. Рішенням є зберігати“
Ця відповідь стосується контракту, основного правила JavaScript, конкретних способів збою, ситуацій конкуренції, прийнятних рішень та того, куди рухається сучасна екосистема. Вона дає інтерв’юеру зрозуміти, що концепція не була просто запам’ятана як правило — розуміється механізм, який за нею стоїть, що саме і має бути виявлено через таке запитання.