Strona główna / Artykuły

Dlaczego funkcje callback w React useEffect nigdy nie powinny być funkcjami asynchronicznymi

Funkcja asynchroniczna przekazana do useEffect narusza zasady czyszczenia w React, ponieważ zwraca Promise. Cztery poprawne wzorce realizacji zadań asynchronicznych wewnątrz efektów.

Wydawca: REACTAPP.TOP14 min czytania3106 słów

Źródła: React Interview Question Asked in Paytm

To pytanie pojawiło się podczas rozmowy kwalifikacyjnej związanej z front-endem w Paytm i zasługuje na większe uznanie, niż sugeruje jego prostota. Na pierwszy rzut oka wydaje się być drobnym problemem składniowym. W rzeczywistości sprawdza, czy rozumiesz cykl życia efektów, jak faktycznie zachowują się obietnice JavaScripta, co React robi wewnętrznie z tym, co zwraca dany efekt, oraz jak prawidłowo pobierać dane wewnątrz komponentu.

Większość kandydatów udziela odpowiedzi, która jest technicznie poprawna, ale dotyka jedynie powierzchni tematu:

"useEffect oczekuje, że zwrócisz funkcję do czyszczenia, natomiast funkcja asynchroniczna zwraca obietnicę (Promise)."

To rozsądny początek, ale pozostawia bez odpowiedzi ważne pytania. Co React robi z tą obietnicą po jej otrzymaniu? Czy po prostu ją odrzuca? Co tak naprawdę się wtedy psuje? A co ważniejsze, jak powinno się strukturyzować logikę asynchroniczną wewnątrz efektu, aby nic się nie zepsuło?

Zbudujmy pełny obraz krok po kroku, sprawdzając każde twierdzenie w dokumencacji React oraz we własnym kodzie źródłowym React.

Część 1: Co oczekuje funkcja useEffect

Funkcję przekazywaną do useEffect oficjalnie nazywa się funkcją konfiguracyjną, a dokumentacja React jest ścisła co do tego, co może ona zwrócić. Istnieją tylko dwa dopuszczalne wyniki:

  • Zwrócenie niczego, co oznacza, że wartość to undefined, albo
  • Zwrócenie funkcji czyszczenia, którą React wywoła później.
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);
}, []);

Funkcja czyszczenia nie jest elementem fakultatywnym – to mechanizm, od którego zależy React przy rozbieraniu wszystkiego, co zostało stworzone na etapie konfiguracji, czy to timera, subskrypcji, słuchacza zdarzeń, czy otwartej połączenia WebSocket. Zgodnie z dokumentacją React, funkcja ta jest wywoływana tuż przed ponownym uruchomieniem efektu oraz jeszcze raz, gdy komponent zostaje ostatecznie usunięty.

Oto szczegół, który sprawia, że całe to zagadnienie jest istotne: React potrzebuje tej funkcji czyszczenia natychmiast, w momencie zakończenia wykonywania funkcji konfiguracyjnej. Czasami czyszczenie musi nastąpić niemal od razu, być może nawet zanim pierwsze await w funkcji asynchronicznej zdąży wykonać się do końca. Funkcja czyszczenia, która pojawia się „w końcu”, jest taką, której React nie może wykorzystać.

Część 2: Dlaczego async zawsze oznacza Promise

To rozdział dotyczy zwykłego zachowania JavaScripta i nie zawiera żadnych wyjątków. Zgodnie z MDN, funkcje deklarowane za pomocą async zawsze zwracają obiekt Promise, i to koniec. Nawet jeśli zwrócisz coś, co jeszcze nie jest obiektem Promise, JavaScript cicho otoczy to dla ciebie takim obiektem. Nie ma żadnej składni, która pozwalałaby ominąć to zjawisko.

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"

Przyjrzyj się uważnie temu, co się tam stało. Kod wyraźnie zwraca funkcję strzałkową, a mimo to wywołujący nadal nie otrzymuje funkcji. Zamiast tego otrzymuje obiekt Promise, który w końcu rozwiąże się do tej funkcji. Dotyczy to nawet funkcji async bez żadnego instrukcji return – i tak zwraca ona obiekt Promise, który rozwiąże się do wartości undefined.

Połącz te dwa elementy:

  1. React oczekuje albo wartości undefined, albo rzeczywistej funkcji, dostarczonej synchronicznie.
  • Funkcja asynchroniczna zawsze zwraca obiekt Promise, bez względu na wszystko.
  • Zatem umowa zostaje już złamana, zanim twoja logika ma szansę zostać wykonywana.

    // 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.
    

    Część 3: Co React faktycznie robi z wartością zwróconą

    Tutaj wiele wyjaśnień błądzi. Powszechnie mówi się, że „React po prostu ignoruje Promise, który zwracasz”. Kod źródłowy opowiada jednak inną historię. React faktycznie wykonuje trzy oddzielne działania z tą zwróconą wartością, a dwa z nich powodują prawdziwe problemy.

    3.1 Przechowuje Promise jako operację czyszczenia

    Głęboko w komponencie reconciler, a konkretnie w funkcji commitHookEffectListMount – odpowiedzialnej za uruchamianie efektów po zapisaniu zmian – React wywołuje funkcję setup i przechowuje jej wartość zwrotną bez żadnych pytań. W wersji produkcyjnej nie ma żadnej kontroli w czasie wykonywania, która mogłaby wykryć niezgodność typów:

    // 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
    

    Jeśli funkcja setup, którą przekazałeś, była asynchroniczna, inst.destroy zawiera teraz oczekujące na realizację Promise w dokładnie tym miejscu, gdzie React oczekuje funkcji do czyszczenia.

    3.2 Wersja rozwojowa wyświetla bardzo konkretne ostrzeżenie

    Gdy wartość zostanie zapisana, procesy budowania aplikacji przeprowadzają dodatkowy krok walidacji. React sprawdza, czy typeof destroy !== 'function', i idzie dalej niż zwykła kontrola typu — konkretnie szuka metody .then w zwróconej wartości. To wyraźny znak, że przekazaliśmy mu Promise zamiast funkcji, a React wskazuje to bezpośrednio:

    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
    

    Zauważ, że ostrzeżenie nie tylko skarży się — zawiera w sobie rozwiązanie, pokazując wzorzec deklaracji funkcji asynchronicznej wewnątrz efektu i jej natychmiastowej wywołania. Gdy tekst błędu samego frameworka już zawiera rozwiązanie, jest to zazwyczaj silny sygnał, że rekruterzy oczekują, iż widzieliśmy to wcześniej.

    3.3 Gdy nadchodzi czas czyszczenia, React próbuje wywołać Promise

    Tutaj sytuacja staje się naprawdę niebezpieczna. Gdy zmieniają się zależności efektu lub komponent jest usuwany, logika usuwania w React sprawdza jedynie, czy coś zostało zapisane jako operacja czyszczenia — nigdy nie weryfikuje, czy zapisana wartość jest wywoływalna:

    // 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
    

    Próba wywołania Promise jakby był to funkcja powoduje błąd TypeError. To właśnie spowodowało dobrze znany awarię „Uncaught TypeError: destroy is not a function” w React 17. W aktualnych wersjach React ten wywołanie jest otoczone ochronnym mechanizmem, więc zamiast sparaliżować całą stronę, błąd jest łapany jako błąd z fazy komitowania i przekazywany najbliższej granicy błędów. W obu przypadkach jest to rzeczywisty błąd w czasie wykonywania, a nie tylko komunikat w konsoli, który można zignorować.

    W sumie mamy do czynienia z trzema odrębnymi błędami:

    1. Prawdziwy kod czyszczenia nigdy nie jest wykonywany, więc timerzy, słuchacze zdarzeń i subskrypcje nigdy nie są usuwane.
    2. Podczas rozwoju React loguje ostrzeżenie.
    3. Później, gdy React próbuje wykonać operację „czyszczenia”, usiłuje zadziałać jako funkcja Promise i wyrzuca błąd.

    Funkcja setup asynchroniczna nie omija zasad po cichu — narusza je w dwóch odrębnych momentach w cyklu życia komponentu.

    Część 4: Porównanie obu ścieżek wykonywania krok po kroku

    Pomaga zestawić funkcję setup synchroniczną z asynchroniczną, krok po kroku. Pierwsze trzy kroki są identyczne. Wszystko się różni począwszy od czwartego kroku.

    Wersja synchroniczna:

    1. Funkcja komponentu jest wykonywana, a JSX jest obliczane.
    2. React zapisuje aktualizację, a przeglądarka renderuje zawartość.
    3. React wywołuje twoją funkcję setup.
  • Funkcja zwraca rzeczywistą funkcję czyszczenia, którą React przechowuje.
  • Każda asynchroniczna logika wewnątrz efektu nadal działa, a aktualizacje stanu są stosowane bez problemów.
  • Gdy zmieniają się zależności lub komponent jest demontowany, React wywołuje przechowywaną funkcję cleanup().
  • Funkcja czyszczenia usuwa interwał, usuwa słuchacz lub zmienia flagę ignorowania — nic nie ucieka.
  • wersja asynchroniczna:

    1. Funkcja komponentu jest wykonywana i obliczany jest JSX.
    2. React zapisuje aktualizację, a przeglądarka rysuje zawartość.
    3. React wywołuje Twoją funkcję setup, która — będąc asynchroniczna — zwraca wynik natychmiast.
    4. Wynikiem jest Promise { <pending> }, a React przechowuje ten Promise tak, jakby był to kod czyszczenia. W środowisku rozwojowym w tym momencie pojawia się ostrzeżenie.
    5. Twoje wywołania await nadal są realizowane w tle, a logika czyszczenia, którą napisałeś, znajduje się wewnątrz tego nierozwiązanego Promise i jest niedostępna.
    6. Gdy zmieniają się zależności lub komponent jest demontowany, React próbuje wywołać przechowywaną wartość — która nie jest funkcją — i rzuci błąd TypeError.
    7. Czyszczenie w ogóle nie zostało wykonane. Interwały nadal działają, a przestarzałe odpowiedzi mogą nadal wywołać setState.

    To porównanie wyjaśnia również, dlaczego umowa wymaga czegoś synchronicznego. Efekty są uruchamiane po zapisaniu zmian, gdy już doszło do ich wyświetlenia, a czyszczenie poprzedniego efektu musi odbyć się tuż przed uruchomieniem następnego – co może nastąpić już podczas kolejnego renderowania. Gdyby React musiał czekać na Promise, aby ustalić, czy istnieje funkcja czyszczenia, cała sekwencja zapisów musiałaby się zatrzymywać na dowolnym kodzie użytkownika, co jest sprzeczne z modelem działania fazy zapisu. To właśnie jest prawdziwy powód, dla którego API zostało zaprojektowane wokół synchronicznych zwrotów, a nie w celu nauczenia Reacta oczekiwania na Promises.

    Część 5: Warunek wyścigu, który ukrywa ten wzorzec

    Istnieje drugie, bardziej subtelne zagrożenie związane z wzorcem asynchronicznym: bez funkcji czyszczenia tracimy możliwość anulowania lub zignorowania przestarzałych zadań asynchronicznych. Dokumentacja samego Reacta ilustruje to na przykładzie strony profilowej, a jej dobrze zrozumienie jest kluczowe, ponieważ nie chodzi tu o błahą kwestię stylistyczną — to błąd, który może trafić do wersji produkcyjnej.

    Oto jak to wygląda w praktyce:

    1. Klikasz na „Bob”, a aplikacja zaczyna pobierać jego biografię.
    2. Zanim to się skończy, klikasz na „Taylor”, co powoduje pobranie jego biografii.
    3. Odpowiedź od Taylora przychodzi pierwsza, więc interfejs pokazuje jego biografię.
    4. Odpowiedź od Boba, która była wolniejsza, przychodzi później i zastępuje aktualny stan.
    5. Na ekranie ostatecznie widnieje Taylor jako wybrany użytkownik, ale poniżej jest pokazana biografia Boba.

    Ponieważ nie ma gwarancji, że odpowiedzi sieciowe zostaną przetworzone w takiej kolejności, w jakiej wysłano żądania, taka niezgodność stanowi rzeczywiste zagrożenie. Standardowym rozwiązaniem jest funkcja czyszczenia, która ustawia flagę ignore, umożliwiając odrzucenie wszystkich odpowiedzi z wyjątkiem tej powiązanej z najnowszym renderowaniem. To rozwiązanie działa tylko wtedy, gdy funkcja setup zwraca wynik synchronicznie, dając Reactowi rzeczywistą funkcję callback do wywołania.

    Zatem asynchroniczna funkcja callback nie jest jedynie technicznym naruszeniem umowy API — ona pozbawia nas właśnie mechanizmu, który zapobiega dotarciu tej sytuacji konkurencyjnej do użytkowników.

    Część 6: Poprawne wzorce

    Każde skuteczne rozwiązanie opiera się na tej samej zasadzie: funkcja setup musi pozostać synchroniczna, a praca asynchroniczna powinna być przekazywana czemuś, co ta funkcja wywołuje. W ten sposób React nadal otrzymuje rzeczywistą funkcję callback do wykonania.

    Wzorzec 1: Nazwana funkcja asynchroniczna wewnątrz efektu (oficjalna rekomendacja)

    To podejście pokazane jest w dokumentacji React i obejmuje flagę ignore, która neutralizuje opisaną powyżej sytuację wyścigu:

    useEffect(() => {
      let ignore = false;
    
      async function startFetching() {
        const json = await fetchTodos(userId);
        if (!ignore) {
          setTodos(json);
        }
      }  startFetching();  return () => {
        ignore = true;
      };
    }, [userId]);
    

    Zewnętrzna funkcja strzałkowa nigdy nie dotyka await, więc return () => { ignore = true; } dostarcza Reactowi prawdziwej funkcji czyszczenia do przechowywania. Tymczasem wewnętrzna funkcja nazwana może swobodnie używać await do obsługi tego, czego potrzebuje.

    Wzorzec 2: Asynchroniczna IIFE

    Działa to w ten sam sposób, tylko z funkcją anonimową, która uruchamia się natychmiast, zamiast z funkcją nazwaną:

    useEffect(() => {
      let ignore = false;
    
      (async () => {
        const data = await fetchUser(id);
        if (!ignore) setUser(data);
      })();  return () => { ignore = true; };
    }, [id]);
    

    Wzorzec 3: Proste łańcuchy .then()

    Dokumentacja referencyjna React faktycznie używa tego stylu we własnym przykładzie pobierania danych:

    useEffect(() => {
      let ignore = false;
      setBio(null);
      fetchBio(person).then(result => {
        if (!ignore) {
          setBio(result);
        }
      });
      return () => {
        ignore = true;
      };
    }, [person]);
    

    Wzorzec 4: AbortController – gdy chcesz rzeczywistego anulowania

    Flaga ignore zapobiega nadpisaniu stanu przez przestarzałą odpowiedź, ale nie powstrzymuje samego żądania przed dokończeniem się w tle. Korzystając z fetch, możesz pójść o krok dalej i bezpośrednio anulować wywołanie sieciowe. Dokumentacja React jasno wskazuje, że operacje czyszczenia powinny albo przerwać żądanie fetch, albo zignorować jego wynik:

    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]);
    

    Który z tych punktów powinien być pierwszy w rozmowie kwalifikacyjnej? Zacznij od wzorca nr 1 – to ten, na który wskazują zarówno ostrzeżenia w trybie deweloperskim React, jak i oficjalne dokumentacje. Wymień wyraźnie flagę ignore, a następnie dodaj, że w przypadku wywołań fetch warto pójść o krok dalej i użyć AbortController, aby anulować żądanie zamiast po prostu odrzucić jego wynik. Ta kombinacja – poprawność, warunki rywalizacji oraz marnotrawstwo ruchu sieciowego – mieści się w trzech zdaniami i obejmuje cały problem.

    Część 7: StrictMode i TypeScript również to wykrywają

    StrictMode. W fazie rozwoju StrictMode celowo uruchamia dodatkowy cykl konfiguracji, a następnie czyszczenia, tuż po początkowym montowaniu. Zgodnie z dokumentacją React ma to na celu sprawdzenie, czy logika czyszczenia rzeczywiście odpowiada logice konfiguracji. Efekt asynchroniczny z natury swojej nie może przejść tej weryfikacji, ponieważ nigdy nie zwraca użytecznej funkcji czyszczenia. To przymusowe podwójne wywołanie jest właśnie tym, co ujawnia dublowane subskrypcje, dublowane interwały oraz dublowane żądania, których obie odpowiedzi trafiają do stanu.

    TypeScript. Definicje typów dla React wbudowują ten kontrakt bezpośrednio do systemu typów:

    // From @types/react (simplified)
    type Destructor = () => void;
    type EffectCallback = () => void | Destructor;
    
    function useEffect(effect: EffectCallback, deps?: DependencyList): void;
    

    Funkcja asynchroniczna ma sygnaturę () => Promise<void>, a ten typ po prostu nie może być przypisany do EffectCallback. Dlatego w projekcie TypeScript ten błąd nawet nie przechodzi kompilacji. To samo w sobie jest godne uwagi: ograniczenie to jest na tyle fundamentalne, że jest egzekwowane w trzech różnych miejscach — w systemie typów, w ostrzeżeniu w trybie rozwojowym oraz w rzeczywistym zachowaniu podczas wykonywania.

    Część 8: Warto wspomnieć o nowoczesnych alternatywach

    Lepsza odpowiedź uwzględnia również fakt, że ręczne pobieranie danych wewnątrz efektów nie jest już uważane za najlepszą praktykę. Same dokumentacje React wskazują, że pobieranie danych bezpośrednio wewnątrz efektów staje się powtarzalne i utrudnia późniejsze dodanie funkcji cacheowania oraz renderowania na serwerze. Zamiast tego proponują kilka alternatyw:

    • Ładowarki na poziomie frameworka. Jeśli projekt wykorzystuje framework taki jak Next.js lub Remix, jego wbudowany mechanizm pobierania danych jest lepszy niż efekty po stronie klienta.
    • Biblioteki cache na stronie klienta. Dokumentacja React wymienia konkretnie TanStack Query, useSWR oraz React Router 6.4+. Te rozwiązania już obsługują eliminację duplikatów, cacheowanie oraz dokładnie te warunki konkurencji, o których mowa powyżej.
  • API use wprowadzone w React 19. Funkcja use(promise) umożliwia komponentowi bezpośrednie odczytanie wartości rozwiązanej z Promise, wstrzymując działanie komponentu, dopóki Promise nie zostanie rozwiązane, i pozwalając mechanizmowi Suspense na wyświetlenie alternatywy. Warto wspomnieć o jednej ważnej uwadze: Promise utworzone podczas renderowania jest tworzone na nowo przy każdym renderowaniu, więc use ma służyć do wykorzystywania obietnic przekazanych z komponentów serwerowych lub zarządzanych przez cache — a nie do bezpośredniego wywołania fetch() w kodzie.
  • Nic z tego nie zastępuje podstawowego wyjaśnienia. Pytanie dotyczące kontraktu efektu naprawdę sprawdza, czy rozumiesz, co React robi wewnętrznie z wartością zwracaną przez Ciebie. Jednak zakończenie czymś w rodzaju „i dlatego, przy rzeczywistym pobieraniu danych, powinieneś użyć TanStack Query lub narzędzia do ładowania frameworków” pokazuje, że wiesz również, jakie są obecne najlepsze praktyki.

    Odpowiedź na wywiad w 30 sekund

    Jeśli masz zapamiętać tylko jedną wersję tej odpowiedzi, to niechą będzie ta:

    "Funkcja setup w useEffect podlega synchronicznemu kontraktowi: musi zwrócić albo nic, albo funkcję do czyszczenia, ponieważ React potrzebuje tej funkcji gotowej do wywołania tuż przed ponownym uruchomieniem efektu oraz za każdym razem, gdy komponent jest usuwany. Funkcja asynchroniczna nigdy nie może spełnić tego wymogu, ponieważ zawsze zwraca Promise, bez względu na to, co się w niej wykona. React przechowuje wtedy ten Promise dokładnie w miejscu, gdzie powinna znajdować się funkcja do czyszczenia: w fazie rozwoju wyświetla ostrzeżenie 'It looks like you wrote useEffect(async () => …)', prawdziwa logika czyszczenia nigdy nie ma szansy zostać uruchomiona, a później, gdy React próbuje wywołać przechowywaną wartość jakby była to funkcja do czyszczenia, pojawia się błąd TypeError. W praktyce objawia się to wyciekami subskrypcji i warunkami wyścigowymi, ponieważ bez działającej funkcji czyszczenia nie ma sposobu na zignorowanie lub przerwanie przestarzałej prośby. Rozwiązaniem jest zachowanie

    Sam callback musi być synchroniczny – należy w nim zdefiniować funkcję asynchroniczną, wywołać tę funkcję i zwrócić mechanizm czyszczenia, który albo ustawia flagę ignorowania na true, albo uruchamia metodę abort w obiekcie AbortController. Do pobierania danych z rzeczywistego świata lepszym wyborem jest loader frameworku lub biblioteka taka jak TanStack Query, ponieważ one automatycznie radzą sobie z takimi warunkami wyścigu.

    Odpowiedź ta dotyczy umowy, leżącej u podstaw zasady JavaScript, konkretnych sposobów awarii, warunku wyścigu, przyjętego rozwiązania oraz tego, w jakim kierunku ewoluuje nowoczesny ekosystem. Pokazuje to rozmówcy, że koncepcja nie została jedynie zapamiętana jako zasada – rozumie się mechanizm stojący za nią, co właśnie ma ujawnić pytanie tego typu.

    Literatura pokrewna

  • Dlaczego Concurrent 401s wylogowuje użytkowników oraz rozwiązanie Single-Flight Refresh — Jak równoległe żądania i rotacja tokenów odświeżania powodują niespodziewane wylogowania, oraz jak jeden wspólny token odświeżania w intercepcie Axios temu zapobiega.