Галоўная / Артыкулы

Чаму калебакі функцияў React useEffect не павінны быть асінхроннымі функцыямі

Функцыя async, якая пасуецца ў useEffect, нарушае прынцыпы чысткі ў React, таму што вяртае Promise. Чатыры правільных падходу для асінхронной роботы ўнутрь effects.

Выдавец: REACTAPP.TOP14 хв чытання3106 слоў

Крыніцы: 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 тылу прыкрывае гэта значэнне 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-функцыі без жадных оператаў вярнення — яна таксама вяртае Promise, які рэшаваецца да undefined.

Спыніце гэтыя два моменты разам:

  1. React чакае або undefined, або рэальную функцыю, якая должна быць доставлена сінхронна.
  • Функцыя асінхроннага типу завжоды вяртае Promise, незалежна ад усьога.
  • Такім чынам, умовы кантракту вже нарушаюцца пры тым, як ваша логіка ефекта мае можлівасць запрацаваць.

    // 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 React зберагае 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
    

    Заўважыце, што паведамленне пра адзінак не толькі скаржыцца — ёно таксама прадае вам рашэння ў самай зместоўцы, паказваючы шаблон выявлення асінхронной функцыі ўнутры effect і яе негайнага вызыву. Калі сам текст адзінака фрэймворку вже містыць рашэння, гэта зазвычай ўпорны сігнал таго, што спаканавальнікі чакаюць, што вы вядомы з гэтым раней.

    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 гэты вызов абгаруецца захістам, таму замест таго, каб усія сторынкі зламаліся, адхэленне ловіцца як адхэленне на стадыі выкарыстання і перадаецца да найбліжэйшага межы адхэлаў. У будзь-якім случае, гэта справжнія проблема пад час выкарыстання, а не проста паведамленне ў консолі, якое можна ігнораваць.

    У сукупнасці мы бачым тры разныя проблемы:

    1. Ваша фактычная функция чысткі ўжо ніколи не выконваецца, таму таймеры, слухачы запуска і падпісанні таксама ніколи не анулююцца.
    2. Пад час розрабатвання React выдае паведамленне пра адзін забой.
    3. Пазней, калі React прабуе выконаць процедуру чысткі, ён намагаецца вызваць Promise як функцію і выкарыстоўвае адпаведную памылку.

    Функція налаштавання асінхроннага типу не праходзіць непазірана праз правіла — яна ўсупершце іх наражае ў двух разныя моменты жыцёвага циклу компонента.

    Частка 4: Порэшанне двух шляхоў выконання па кроках

    Дапамагае порэшанне сінхронной функцыі налаштавання з асінхронной, крок за крокам. Першыя тры крокі збігаюцца абсалютна. Усё розходзіцца пачынкаючы з чатвертага кроку.

    Сінхронная версія:

    1. Выконваецца функцыя компонента, і вырахоўванае JSX.
    2. React фіксуе апдэйт, і браўзер атрыбутуе змест.
    3. React вызывае вашу функцію налаштавання.
  • Функцыя вяртае рэальную функцыю чысткі, якая зберагаецца ў React.
  • Будзь-яя асінхронная логіка ўнутры effect продовжае выкананне, а апдэты статаусу прыменяюцца без проблем.
  • Калі зменяюцыся залежнасці або компонент дэмонтуюцца, React вызывае зберажаную cleanup().
  • Функцыя чысткі анулюе інтэрвал, выкарыстоўваецца лісенар або зменяе флаг ігноравання — нічога не трапляецца.
  • Асінхронная версія:

    1. Выкананы функцыя компонента, і вырахоўваецца JSX.
    2. React фіксуе апдэт, і браўзер атрыбутуе ельменты.
    3. React вызывае вашу функцыю setup, якая, будучы асінхронной, вяртае рэзультат мгнова.
    4. Рэзультатам яе выканання ёсць Promise { <pending> }, і React зберагае гэты Promise як чыстку. У режыме разработкі тут выдаецца апавешчэнне.
    5. Вашы вызванні await яшчэ обрабоцваюцца на фоне, а любая логіка чысткі, яку вы напісалі, знаходзіцца внутрь таго нераспрацаванага Promise і є недоступной.
    6. Калі змянююцца залежнасці або компонент дэмонтуюцься, React прагне вызваць зберажанае значэння — якое не ў формате функцыі — і выклікае TypeError.
    7. Логіка чысткі так і не была запрацавана. Інтэрвалы продовжваюць працаваць, а застарэлыя адпаведзі можаюць яшчэ спрычыніць вызванне setState.

    Гэтыя параграфы таксама раз'яснююць, чаму кантракт выклікае синхронную дзейнасць. Эфекты выклікаюцца пасля адкладання змян, калі рэндарыжчы вже завершыўся, а процес чысткі пакананнага эфекту должен выконвацца адразу перед тым, як выклікаецца наступны эфект — гэта можа здарыцца ўже пад час наступнага рэндарыжчы. Якбы React павінен быў чакаць на Promise, каб толькі выявіць, чы існуе функцыя чысткі, весь процес адкладання змян бы павінен быў зупініцца на дэтальным коде корыстніка, што суперчыць прыроде фазы адкладання змян. Гэта справжня прычына, чаму API быў створаны на аснове синхронных рэзультатаў, а не на аснове навучання React чакаць на вашы Promise.

    Частка 5: Умовы канкурэнціі, якія маскуюць гэты патэрн

    Існуе ўзгародны, болей тонкі аб’якт для патэрна async: без функцыі чысткі вы трапляецеся без можлівасці анулювацыі чырага занятых асінхронных задач. Сама дакументацыя React ілюструе гэта на прыкладзе стороні з персоналіямі, і ўжоць варта гэта добра зрозумець, таму што гэта не проста стылістычная дробязга — гэта баг, які можна запусціць у працоўны сервіс.

    Ось як гэта выглядае:

    1. Вы клацаеце на «Bob», і прыстрой запускае загрузку його біяграфіі.
    2. Перш чым гэта завершыцца, вы клацаеце на «Taylor», чым запускаеце загрузку яго біяграфіі.
    3. Адпаведна дзеянне ўжо Taylor прыходзіць першай, таму інтэрфейс паказвае яго біяграфію.
    4. Адпаведна дзеянне ўжо Bob, якае было медленейшым, прыходзіць пазней і заменяе стан.
    5. У канцыкуле на экране паказваецца 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 справжню функцыю для чысткі, яку можна зберагчы. У той жа час внутрэшняя функцыя з іменем можа свабодна чакаць на тое, што ёй патрэбна.

    Шаблон 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+. Яны вже карацяюць падвойнам запісу, кэшаванню і тым самым усім вышэй згаданым ситуацыям карбалкавання.
  • API use, яка была адаптавана ў React 19. Функцыя use(promise) дазволяе компаненту безпасебна чытаць значэнне, якое вже было рашыранае Promise, при чым компанент начаквае, пакуль Promise застаецца нерашытым, і дазволяе механізму Suspense атрыбутаваць заменны контэнт. Важна прыметка, якую варта згадаць: Promise, створаные пад час рэндара, перзначаюцца ў кожны раз пад час рэндара, таму use падходзіць для выкарыстоўвання Promise, якія былі пераданы з Server Components або керуецца за дапамогою кэша — а не для простаго вызову fetch() у коде.
  • Нічыя з гэтых рашэнняў не можа заменіць основнае пояснэнне. Запытанне пра кантракты эфекта на самай справе пераканальвае, чы розуміяце вы, што React рабіць унутрана з вашымя значэннямі, якія вы вяртаеце. Але заканчэнне на кшталт «і самэ гэта пры рэальным запрашоўванні дадзеных вы мусіце вжываць TanStack Query або іншы фрэймворк-лодар» паказвае, што вы таксама знаёце, якія ў дзейнай часу є найлепшыя практыкі.

    Адказ на інтэрв’ю за 30 секундаў

    Якщо вы хочаце запамятаць толькі адну версію гэтага адказу, запамятаце гэты:

    "Функцыя setup у useEffect выконвачваецца сінхронна: яна должна вярнуць або нічога, або функцыю для чысткі, таму што React патрэбуе, каб гэта чысткі было готава да выканання непраздлейшнае перед тым, як эфект зноў запрацуе, і ў кожны момент, калі компонент адключаецца. Асінхронная функцыя ніколі не можа задовольніць гэтыя трэбавання, таму што асінхронныя функцыі завжды вяртаюць Promise, незалежна ад таго, што выканваецца ў іхнім тэле. React пасля чаго зберагае гэты Promise у тым самым месцы, дзе патрэбна функцыя для чысткі: у режыме разработкі ён выводзіць адпаведную паветраду, рэальная логіка чысткі так і не мае можлівасці выканацца, а пазней, калі React прабуе вызваць зберажаныя данні як функцыю для чысткі, выйшваеся TypeError. У практыцы гэта прыводзіць да витокаў сабскрыпшынаў і супернічанняў, таму што без правяльнай функцыі чысткі немагчыма ігнараваць або абрануць застарэлы запит. Рашэння — старавацца..."

    Калебэк сам по сабе ў сінхронным режыме; трэба адзначыць асінхронную функцыю ўнутры яго, вызваць гэтую функцыю і вернуць механізм для чысткі, який альбо ставить флаг ignore у значэнне true, альбо запускае метод abort у об’екте AbortController. Для захоплення дадзэнняў у рэальным свете кращы выбар — ладчык фреймворку або бібліятэка на кшталт TanStack Query, адколі гэтыя інструменты вже автаматычна керуюць такімі ситуацыями конкурэнціі.

    У гэтым адказе рассказваецца пра контракт, асновныя правіла JavaScript, конкрэтыя способы парадох, ситуацыю конкурэнціі, прийнятныя способы рашэння проблемы і тое, куды развіваецца сучасны екоспектр. Гэта паказвае спрашанніку, што концэпцыя не была проста запам’ятана як правіло — розумецца механізм, який стоіць за ёю, і самэ гэта є метай такога пытання.