Przejdź do treści
Jakość kodu

Pole ma 7 znaków, lista pokazuje wyniki dla 4. Starsza odpowiedź wróciła ostatnia i nadpisała stan

Wygenerowany komponent wyszukiwania zapisuje do stanu odpowiedź, która wróciła ostatnia, a nie najnowszą. Co wyszukać w diffie i jak odtworzyć błąd testem.

Redakcjadeveloper front-end10 minkod + tabela
7liczba z tezy tekstu

W polu wyszukiwania jest siedem znaków, a lista pod nim pokazuje wyniki dla czterech. Użytkownik dopisał trzy litery, lista mrugnęła i wróciła do stanu z poprzedniej frazy. Odświeżenie strony naprawia objaw, więc zgłoszenie trafia do kategorii „czasem się rozjeżdża” i leży tam tydzień.

Testy tego komponentu przechodzą. Przegląd kodu też go przepuścił, bo w diffie nie ma nic, co wygląda na błąd: jest useEffect, jest fetch, jest zapis do stanu. Lint milczy. Problemem nie jest żadna z tych linii osobno, tylko to, czego w nich nie ma.

Kolejność odpowiedzi nie jest kolejnością żądań

Komponent wygenerowany pod opis „lista produktów filtrowana tekstem z pola” dostaje najczęściej taki kształt:

useEffect(() => {
  fetch(`/api/produkty?q=${q}`)
    .then((r) => r.json())
    .then((dane) => setWyniki(dane.items));
}, [q]);

Każdy znak wpisany w pole zmienia q, każda zmiana q uruchamia efekt, każdy efekt wysyła żądanie. Przy czterech znakach leci jedno zapytanie, przy siedmiu — kolejne. Serwer nie ma obowiązku odpowiedzieć na nie w tej samej kolejności i przy szerszej frazie zwykle odpowiada szybciej, bo ma mniej wierszy do zwrócenia. Dokumentacja Reacta nazywa to wprost: odpowiedzi sieciowe mogą przychodzić w innej kolejności, niż zostały wysłane. Wtedy wygrywa nie najnowsza odpowiedź, a ta, która wróciła ostatnia.

Druga połowa problemu to brak zwrotu z efektu. Bez funkcji czyszczącej nie ma miejsca, w którym kod mógłby zaznaczyć, że wynik poprzedniego żądania już nikogo nie interesuje. setWyniki wykona się tyle razy, ile odpowiedzi wróci, w kolejności ich powrotu.

Dlaczego lint tego nie zgłasza

Reguła react-hooks/exhaustive-deps sprawdza, czy tablica zależności zawiera wszystkie potrzebne zależności, i pilnuje klasy błędów z nieaktualnym domknięciem. W kodzie powyżej q jest w tablicy, więc regule nie ma czego zgłosić. Zielony lint mówi, że zależności są kompletne — nie mówi nic o tym, co się stanie, gdy dwa wywołania efektu będą żywe jednocześnie.

Sześć wzorców, które w diffie zapalają lampkę

To lista do wyszukania w zmienionych plikach, nie do czytania w locie. Każdy z tych wzorców sam z siebie kompiluje się i przechodzi testy jednostkowe.

Co widać w kodzie Czego brakuje Objaw u klienta
useEffect z fetch i bez return funkcji czyszczącej stan z nieaktualnego żądania
setX po await w funkcji wywołanej z efektu flagi odcinającej wynik to samo, trudniejsze do zauważenia w diffie
.then(setWyniki) podane wprost jako referencja miejsca na warunek brak punktu, w który da się wstawić gwardię
fetch bez signal w opcjach możliwości przerwania transfer i praca serwera po porzuceniu wyniku
setTimeout jako debounce bez clearTimeout czyszczenia licznika seria żądań zamiast jednego, mnoży problem
setX po await w handlerze onChange czegokolwiek — handler nie ma cyklu życia ten sam wyścig, poza zasięgiem funkcji czyszczącej

Ostatni wiersz jest najgorszy z punktu widzenia przeglądu, bo nie ma w nim efektu, więc nie ma też nawyku szukania tam cleanupu. Zapis do stanu po await w obsłudze zdarzenia wymaga własnego licznika żądań albo kontrolera trzymanego w useRef.

Wiersz z setTimeout wymaga osobnego komentarza, bo debounce bywa oddawany jako naprawa tego błędu. Nie jest. Opóźnienie wysyłki zmniejsza liczbę żądań w locie i tym samym częstotliwość objawu, ale nie usuwa przypadku, w którym dwa żądania są żywe jednocześnie — wystarczy, że użytkownik przestanie pisać na dłużej niż próg debounce’u, a potem dopisze resztę frazy. Debounce bez clearTimeout w funkcji czyszczącej jest przy tym gorszy od jego braku, bo zostawia zaplanowane wysyłki po odmontowaniu komponentu.

Powtórzenie błędu na żądanie: dwa opóźnienia w jednym route

Dławienie sieci w narzędziach przeglądarki opóźnia wszystkie żądania naraz, więc kolejność się nie odwraca i objaw nie wychodzi. Potrzebne jest opóźnienie zależne od treści zapytania: starsze, węższe żądanie ma wrócić później niż nowsze.

Playwright przechwytuje żądania przez page.route i odpowiada przez route.fulfill. Opóźnienia nie ma w opcjach fulfill, ale handler jest asynchroniczny, więc wystarczy w nim poczekać:

await page.route('**/api/produkty*', async (route) => {
  const url = new URL(route.request().url());
  const q = url.searchParams.get('q') ?? '';
  const opoznienie = q.length <= 4 ? 1200 : 150;
  await new Promise((gotowe) => setTimeout(gotowe, opoznienie));
  await route.fulfill({ json: { items: [{ id: q, nazwa: `wynik dla ${q}` }] } });
});

await page.getByRole('searchbox').fill('krawa');
await page.getByRole('searchbox').fill('krawaty');
await expect(page.getByRole('listitem').first()).toHaveText('wynik dla krawaty');

Progi 1200 ms i 150 ms nie są pomiarem produkcji, tylko ustawieniem testu: mają gwarantować odwrócenie kolejności w każdym przebiegu. Na kodzie bez gwardii ten test pada z wynikiem wynik dla krawa i pada deterministycznie, co jest jedyną różnicą między błędem naprawialnym a zgłoszeniem „czasem się rozjeżdża”.

Ten sam test po poprawce przechodzi i zostaje w repozytorium jako ochrona przed powrotem wzorca. Jeśli w projekcie jest kilka takich pól, warto parametryzować go listą ścieżek, zamiast kopiować plik.

Jak to rozpoznać w zgłoszeniu, do którego nie ma dostępu

Zgłoszenia tej klasy przychodzą bez kroków odtworzenia, bo użytkownik nie wie, że dopisał trzy litery szybciej niż zwykle. Rozstrzyga porównanie dwóch rzeczy w jednym momencie: treści pola i zapytania, które wyprodukowało widoczną listę. Jeśli endpoint odbija frazę w odpowiedzi, kontrola schodzi do jednego warunku przy zapisie do stanu i nie zależy od mechaniki Reacta:

.then((dane) => {
  if (dane.q === q) setWyniki(dane.items);
  else licznikRozjechanych.current += 1;
});

Taki licznik wysłany do telemetrii odpowiada na pytanie, czy problem jest realny w produkcji, czy tylko możliwy w teorii — i na jakim odsetku sesji. Jeśli endpoint frazy nie odbija, ten sam efekt daje własne pole w odpowiedzi albo nagłówek echa, i to jest zwykle najtańsza zmiana po stronie API, jaką warto wziąć razem z poprawką front-endu. Sprowadza się to do ustalenia tożsamości żądania po stronie odbiorcy odpowiedzi — tego samego problemu, który w automatyzacji poczty rozwiązuje się kluczem idempotencji i identyfikatorem wątku, tylko w skali jednego komponentu i jednej sesji.

Flaga ignore i AbortController załatwiają dwie różne rzeczy

Dokumentacja Reacta pokazuje wariant z flagą: zmienna ignore ustawiana na true w funkcji czyszczącej, sprawdzana przed zapisem do stanu.

useEffect(() => {
  let nieaktualne = false;
  fetch(`/api/produkty?q=${q}`)
    .then((r) => r.json())
    .then((dane) => {
      if (!nieaktualne) setWyniki(dane.items);
    });
  return () => {
    nieaktualne = true;
  };
}, [q]);

Wariant drugi przerywa samo żądanie. AbortController jest dostępny we wszystkich głównych przeglądarkach od marca 2019 r., więc nie wymaga już zapasowej ścieżki:

useEffect(() => {
  const kontroler = new AbortController();
  fetch(`/api/produkty?q=${q}`, { signal: kontroler.signal })
    .then((r) => r.json())
    .then((dane) => setWyniki(dane.items))
    .catch((e) => {
      if (e.name !== 'AbortError') setBlad(e);
    });
  return () => kontroler.abort();
}, [q]);

Warunek w catch nie jest kosmetyką. Wywołane bez argumentu abort() ustawia powód na DOMException o nazwie AbortError, a odrzucony fetch trafia do tego samego catch, co awaria sieci. Kod, który dostał kontroler, a zachował naiwne catch (e) { setBlad(e) }, zamienia jeden błąd na drugi: lista przestaje się rozjeżdżać, ale przy szybkim pisaniu zaczyna błyskać komunikatem o błędzie na każdym znaku. W przeglądzie to osobna rzecz do sprawdzenia, bo generator chętnie dodaje kontroler bez ruszania obsługi wyjątku.

Mechanizm Co naprawia Co zostaje
flaga w funkcji czyszczącej zapis nieaktualnego wyniku do stanu żądanie leci do końca, transfer i koszt serwera opłacone
AbortController jedno i drugie wymaga odfiltrowania AbortError w każdej gałęzi błędu
licznik żądań w useRef wyścig w handlerze zdarzenia, gdzie nie ma cleanupu własny stan do utrzymania, łatwo pominąć przy refaktorze

StrictMode pokazuje brakujący cleanup, nie odwróconą kolejność

W trybie StrictMode React uruchamia jeden dodatkowy cykl setup i cleanup dla każdego efektu, żeby wydobyć błędy wynikające z braku czyszczenia. Podwójne żądanie w zakładce sieciowej po wejściu na stronę to właśnie ten sygnał — i jedyny z omawianych, który widać bez pisania testu.

Dwie pułapki w czytaniu tego sygnału. Pierwsza: brak podwójnego żądania nie oznacza, że efekt jest poprawny, bo wyścig między dwoma różnymi zapytaniami wymaga dwóch różnych wartości q, a tego StrictMode nie symuluje. Druga: wygenerowana „poprawka” na podwójne żądanie bywa wartownikiem w useRef, który blokuje drugie wykonanie efektu. To wycisza objaw w trybie deweloperskim i nie zmienia nic na produkcji. Kryterium z dokumentacji jest inne: użytkownik nie powinien odróżnić jednokrotnego setupu od sekwencji setup → cleanup → setup, czyli czyszczenie ma cofać to, co robi setup.

Kiedy przestać łatać efekty i przełożyć to na warstwę zapytań

Gwardia w efekcie jest poprawna, ale nie skaluje się przez przegląd: trzyma się jej ten, kto o niej wie. Przy kilkunastu komponentach pobierających dane ręcznie koszt pilnowania wzorca jest większy niż koszt warstwy, która robi to z definicji. Dokumentacja Reacta stawia to w tej kolejności: jeśli używasz frameworka, korzystaj z jego mechanizmu pobierania danych, w przeciwnym razie rozważ cache po stronie klienta — TanStack Query, useSWR, React Router.

Co taka warstwa daje w tym konkretnym błędzie: TanStack Query przekazuje każdej funkcji zapytania instancję AbortSignal, przerywaną gdy zapytanie staje się nieaktualne lub nieaktywne. Warto znać jedno domyślne zachowanie z tej samej dokumentacji: zapytania, które stracą obserwatorów, nie są domyślnie anulowane, a ich wyniki trafiają do cache’u. Dla spójności stanu to jest w porządku, dla rachunku za API — osobna decyzja, którą podejmuje się świadomie, przekazując signal dalej do fetch.

Część efektów w takim kodzie nie powinna w ogóle istnieć. Jeśli efekt tylko zeruje stan po zmianie identyfikatora, dokumentacja proponuje zamiast tego nadanie komponentowi key równego temu identyfikatorowi — React odtworzy wtedy komponent i wyzeruje stan jego i dzieci. Takie efekty generator produkuje hurtowo, bo zerowanie stanu po zmianie propsa wygląda w opisie zadania jak zadanie dla efektu.

Cztery kontrole do protokołu przeglądu

  • Każdy efekt z fetch albo await ma return. Kryterium binarne, da się sprawdzić wzrokiem w diffie i wpisać do listy odbioru.
  • Każdy catch na ścieżce z kontrolerem odfiltrowuje AbortError. Inaczej poprawka wymienia rozjeżdżającą się listę na błyskający komunikat o błędzie.
  • Zapisy do stanu po await w handlerach zdarzeń mają własną gwardię. Funkcja czyszcząca ich nie obejmuje, a w przeglądzie wypadają z uwagi.
  • Co najmniej jedno pole wyszukiwania ma test z odwróconymi opóźnieniami. Zielony lint i zielone testy jednostkowe nie dotykają tej klasy błędów.

Dla konkretnego komponentu decyzja jest jedna z dwóch: albo efekt zostaje i dostaje funkcję czyszczącą plus filtr na AbortError, albo komponent przechodzi na warstwę zapytań i efekt znika razem z własnym stanem ładowania. Progiem między tymi wariantami jest liczba miejsc w projekcie, które pobierają dane ręcznie — przy dwóch taniej jest dopisać gwardię, przy dwunastu taniej wprowadzić cache. Czego nie ma w tym wyborze: trzeciej opcji, w której wystarcza zielony lint.