Blog
useEffect na produkcji: stale closures, cleanup i efekty uruchamiane w niewłaściwym momencie
Stara subskrypcja aktualizuje nowy widok, a odświeżenie kasuje szkic. Jak ustalać zależności i czas życia efektów oraz testować callbacki, timery i cleanup bez czekania na sieć.
Po zmianie filii wracają zgłoszenia z poprzedniej
Operator przełącza panel biblioteki z harbour na hill. Nagłówek zmienia się od razu. Po chwili licznik znów pokazuje zgłoszenia poprzedniej filii. Odświeżenie strony pomaga — do następnego przełączenia.
To fikcyjny przykład, który będzie nam towarzyszył w całym artykule: panel zgłoszeń awarii w sieci bibliotek, z aktualizacjami na żywo, opcjonalnym odpytywaniem API i notatką do przekazania dyżuru. Opisuje prawdopodobny błąd produkcyjny, a nie incydent z projektu GiSoft. Dłuższa sesja ujawnia to, czego nie widać przy szybkim sprawdzeniu lokalnie: callback utworzony dla jednej filii nadal działa po wybraniu drugiej.
Zacząłbym od ustalenia, do którego widoku należy ta aktualizacja. Jaki identyfikator zapamiętał callback? Co miało zakończyć jego działanie po zmianie filii?
Moment Widok Praca poza Reactem
t0 harbour subskrypcja zapamiętuje harbour
t1 hill stary callback pozostaje w kolejce
t2 hill dociera aktualizacja harbour
Błąd: licznik harbour pod nagłówkiem hill
Poprawka: stary callback pominięty; hill przyjmuje swoje daneSzybkie API i krótkie sesje maskują nakładanie się operacji. Zmienna latencja, karty w tle i wielokrotna nawigacja zwiększają szansę jego ujawnienia. Chodzi o czas życia pracy asynchronicznej, nie o rzekomą kolejność pakietów HTTP.
Co właściwie wymaga efektu?
useEffect może synchronizować zatwierdzony widok Reacta z zasobem zewnętrznym: subskrypcją, nasłuchem zdarzeń przeglądarki, timerem czy widgetem obsługiwanym imperatywnie. Zależności wskazują wartości, których zmiana wymaga ponownej synchronizacji. Przed uruchomieniem setupu dla nowych zależności React wykonuje poprzedni cleanup; wykonuje go również przy odmontowaniu komponentu.
Samo „po renderze” niewiele pomaga w projektowaniu. Efekt dotyczy zatwierdzonego renderu, działa po stronie klienta i nie daje ogólnej gwarancji, że przeglądarka zdążyła już narysować ekran. Obliczenie na podstawie bieżących propsów nie potrzebuje takiej granicy. Kliknięcie ma własny handler. Pobieraniem danych może już zarządzać warstwa routingu lub klient API.
Przykłady korzystają z Reacta 19.2 i TypeScriptu. Sprawdzony frontend używa Next.js 16 App Router, Vitest i React Testing Library. Poniższe DeskFeed i DeskReader to kontrakty stworzone na potrzeby artykułu, nie API Reacta ani istniejące helpery projektu. Rzeczywisty adapter odpowiadałby za uwierzytelnienie transportu, odczyt i weryfikację danych, ponowne połączenia oraz kolejność zdarzeń.
export type DeskScope = {
branchId: string;
sessionEpoch: number;
};
export type DeskSnapshot = {
branchId: string;
waiting: number;
};
export interface DeskFeed {
watch(
scope: DeskScope,
receive: (snapshot: DeskSnapshot) => void,
): () => void;
}
export interface DeskReader {
read(scope: DeskScope, signal?: AbortSignal): Promise<DeskSnapshot>;
}sessionEpoch oznacza lokalną generację zmienianą przy zmianie kontekstu uwierzytelnienia. Nie zawiera poświadczeń i nie nadaje uprawnień. Pozwala zakończyć pracę należącą do poprzedniej sesji. Backend nadal musi autoryzować żądania i subskrypcje.
Timer może nadal korzystać ze starego renderu
Ten celowo błędny hook wygląda dość porządnie: uruchamia interwał i usuwa go w cleanupie. Pusta tablica zależności powoduje jednak, że callback zachowuje początkowe branchId, sessionEpoch i reader.
import { useEffect, useState } from 'react';
import type { DeskReader, DeskSnapshot } from './deskContract';
export function useFrozenDesk(
branchId: string,
sessionEpoch: number,
reader: DeskReader,
) {
const [snapshot, setSnapshot] = useState<DeskSnapshot | null>(null);
useEffect(() => {
const timer = window.setInterval(() => {
void reader.read({ branchId, sessionEpoch })
.then(setSnapshot)
.catch(() => setSnapshot(null));
}, 20_000);
return () => window.clearInterval(timer);
}, []);
return snapshot;
}Zmiana filii na ekranie nie tworzy nowego callbacku. Dwadzieścia sekund to przykładowy interwał, nie gwarancja dokładnego terminu wykonania. Jeśli odczyt już się rozpoczął, usunięcie interwału nie przeszkodzi też jego odpowiedzi w wywołaniu setSnapshot.
To właśnie stale closure: callback przechowuje wartości z renderu dłużej, niż pozostają one właściwe dla jego zadania. Domknięcia są zwykłym mechanizmem JavaScriptu. Błędem jest oczekiwanie, że callback automatycznie otrzyma wartości z kolejnego renderu. Funkcyjna aktualizacja stanu pomaga obliczać nowy stan na podstawie poprzedniego, lecz nie podmienia przechwyconego identyfikatora filii ani kontekstu logowania.
Zależności opisują synchronizację, a nie pożądaną częstotliwość
Dodanie brakujących zależności jest tutaj konieczne, ale nadal trzeba obsłużyć odczyt będący w toku. Jeżeli po poprawieniu ostrzeżenia lintera połączenie zaczyna odnawiać się bez przerwy, sprawdź tożsamość zależności.
Przykładowo const scope = { branchId, sessionEpoch } w ciele komponentu tworzy nowy obiekt przy każdym renderze. Zależność od scope odnowi efekt, mimo że oba pola się nie zmieniły. React porównuje zależności przez Object.is, a nie przez zawartość obiektów. Mały obiekt można utworzyć wewnątrz efektu, pozostawiając w tablicy jego proste wartości wejściowe.
Podobnie zachowują się funkcje definiowane inline i stale tworzone klienty. Efekt używający feed naprawdę zależy od tej instancji. Nadaj klientowi świadomego właściciela zamiast ukrywać go przed tablicą zależności. useMemo i useCallback bywają przydatne do stabilizacji wartości, ale nie naprawią efektu, który powinien być obliczeniem albo jawną akcją.
W sprawdzonym stosie plugin React Hooks dla ESLinta jest zainstalowany przez narzędzia Next.js. Nie znalazłem jednak aktywnej konfiguracji ESLinta ani skryptu lintowania. Obecność paczki nie dowodzi więc, że działa exhaustive-deps. Taką kontrolę należy włączyć zgodnie z konfiguracją projektu, a nie wyciszać ostrzeżenia w przykładach.
Subskrypcja należy do filii i sesji
Wariant z aktualizacjami na żywo nadaje każdej subskrypcji własny callback i funkcję wypisania. Cleanup najpierw odbiera callbackowi możliwość aktualizacji widoku, a dopiero potem zwalnia subskrypcję. To obejmuje również pracę, którą adapter zdążył już umieścić w kolejce.
'use client';
import { useEffect, useState } from 'react';
import type { DeskFeed, DeskSnapshot } from './deskContract';
type Props = {
branchId: string;
sessionEpoch: number;
feed: DeskFeed;
};
export function DeskActivity(props: Props) {
const identity = `${props.sessionEpoch}:${props.branchId}`;
return <ActivityScope key={identity} {...props} />;
}
function ActivityScope({ branchId, sessionEpoch, feed }: Props) {
const [snapshot, setSnapshot] = useState<DeskSnapshot | null>(null);
useEffect(() => {
let acceptsEvents = true;
const stop = feed.watch({ branchId, sessionEpoch }, next => {
if (acceptsEvents && next.branchId === branchId) {
setSnapshot(next);
}
});
return () => {
acceptsEvents = false;
stop();
};
}, [branchId, sessionEpoch, feed]);
return (
<section>
<h2>{branchId}</h2>
<p role="status">
{snapshot ? `${snapshot.waiting} oczekujących zgłoszeń` : 'Oczekiwanie na dane'}
</p>
</section>
);
}key resetuje tylko poddrzewo bieżącego statusu przy zmianie filii lub sesji. Dzięki temu stary snapshot nie zostaje pod nowym nagłówkiem na czas nawiązywania subskrypcji. Klucz nie zastępuje cleanupu. Formularz z niezapisanym tekstem powinien pozostać poza tą granicą resetu, chyba że świadomie chcemy odrzucić jego szkic.
Sprawdzenie filii odrzuca błędnie skierowane zdarzenie. acceptsEvents blokuje pracę zakończonej subskrypcji, również po wymianie feed dla tej samej filii. Przykład zakłada, że adapter dostarcza snapshoty w kolejności w obrębie jednej aktywnej subskrypcji. Jeżeli transport może zmienić kolejność wersji, kontrakt potrzebuje także kontroli rewizji.
Rodzaj zasobu i zakres jego własności określają cleanup:
Zasób należący do widoku Zwolnienie zasobu
Subskrypcja filii wypisanie z danego tematu
Wspólny WebSocket usunięcie własnej subskrypcji
Timer usunięcie zaplanowanego callbacku
Listener przeglądarki usunięcie tej samej funkcji
Żądanie odczytu odrzucenie wyniku; abort, jeśli działa
Widget imperatywny jego metoda dispose/destroyZamknięcie wspólnego socketu przez jeden panel może odciąć pozostałe. Komponent tworzący prywatne połączenie zwykle odpowiada natomiast za jego zamknięcie. Setup i cleanup muszą zgadzać się co do tego zakresu.
Cleanup może istnieć i niczego nie usuwać
Kolejny błędny hook odświeża dane po zmianie widoczności karty. Setup rejestruje jedną funkcję, a cleanup przekazuje drugą, nowo utworzoną, choć zapisaną identycznie.
import { useEffect } from 'react';
export function useLeakingResume(
branchId: string,
refresh: (branchId: string) => void,
) {
useEffect(() => {
document.addEventListener('visibilitychange', () => refresh(branchId));
return () => document.removeEventListener(
'visibilitychange', () => refresh(branchId),
);
}, [branchId, refresh]);
}removeEventListener wymaga tej samej referencji callbacku i zgodnego ustawienia capture. W tym wariancie zmiany filii dokładają więc kolejne listenery. Powrót do karty wywołuje kilka odczytów, także dla niewidocznych już filii.
Zachowaj callback w zakresie setupu i usuń dokładnie tę funkcję:
import { useEffect } from 'react';
export function useDeskResume(
branchId: string,
refresh: (branchId: string) => void,
) {
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === 'visible') refresh(branchId);
};
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
}, [branchId, refresh]);
}refresh jest tutaj synchroniczną funkcją zlecającą odczyt, należącą do tej funkcjonalności. Jej implementacja obsługuje błędy asynchroniczne i aktualność odpowiedzi; hook odpowiada wyłącznie za listener. Jeżeli referencja refresh zmienia się co render, listener zostanie bezpiecznie wymieniony, ale niepotrzebnie. Najpierw ustal właściciela tej funkcji, potem rozważ memoizację.
Polling potrzebuje czasu życia, nie tylko interwału
Wolny odczyt może trwać dłużej niż odstęp między wywołaniami setInterval. W naszym panelu polling jest alternatywą dla subskrypcji, a nie drugim równoległym źródłem aktualizacji. Zaplanowanie następnego odczytu dopiero po zakończeniu poprzedniego eliminuje nakładanie się odczytów w jednym cyklu życia.
import { useEffect } from 'react';
import type { DeskReader, DeskSnapshot } from './deskContract';
export function useDeskPolling(
branchId: string,
sessionEpoch: number,
reader: DeskReader,
receive: (snapshot: DeskSnapshot) => void,
report: (error: unknown) => void,
pauseMs = 20_000,
) {
useEffect(() => {
let retired = false;
let timer: number | undefined;
const controller = new AbortController();
async function refresh() {
try {
const next = await reader.read(
{ branchId, sessionEpoch }, controller.signal,
);
if (!retired) receive(next);
} catch (error) {
if (!retired && !controller.signal.aborted) report(error);
} finally {
if (!retired) timer = window.setTimeout(refresh, pauseMs);
}
}
void refresh();
return () => {
retired = true;
controller.abort();
window.clearTimeout(timer);
};
}, [branchId, sessionEpoch, reader, receive, report, pauseMs]);
}Hook powinien działać wewnątrz tej samej granicy filii i sesji. receive i report są stabilnymi callbackami funkcjonalności, na przykład setterami stanu Reacta; kontrakt readera zwraca snapshot żądanej filii. Interwał jest minimalną przerwą po zakończeniu odczytu. Ograniczenia pracy kart w tle i planowanie przez przeglądarkę mogą ją wydłużyć.
Cleanup usuwa następny timer, przerywa transport, który obsługuje anulowanie, i unieważnia callbacki nawet wtedy, gdy adapter nie potrafi przerwać operacji. Odczyt musi ostatecznie się zakończyć lub respektować anulowanie, żeby polling mógł postępować. Fragment nie implementuje timeoutów transportu ani backoffu. Zakłada też, że callbacki nie rzucają wyjątków. To kontrakty adaptera i funkcjonalności, nie kolejne obowiązki uniwersalnego hooka od timerów.
Nieaktualne dane, błędy i loading mają wspólny problem
Dodanie zależności może rozpocząć odczyt B, gdy A nadal trwa. B kończy się sukcesem, a później A zgłasza błąd. Jeżeli catch A zapisze go do bieżącego stanu, poprawne dane znikną. Bezwarunkowe finally { setLoading(false) } powoduje inny błąd: A się kończy, B nadal trwa, lecz wskaźnik ładowania gaśnie.
Zmiany danych, błędu i stanu ładowania muszą należeć do tej samej aktywnej operacji. Sprawdzaj tę własność przy każdej z nich albo powierz ją istniejącej warstwie zapytań. Jeden boolean wystarczy dla jednej sekwencyjnej operacji; nie opisze kilku niezależnych żądań. Hook pollingu zapobiega nakładaniu się odczytów w jednym cyklu i odrzuca callbacki ze starych cykli. Nie koordynuje wszystkich żądań w całej funkcjonalności.
Celowe anulowanie po nawigacji zwykle nie powinno wyświetlać komunikatu o awarii serwera. Sprawdź kontrakt klienta HTTP: wrapper może przekształcić pierwotny wyjątek. Stan własnego sygnału albo jawny wynik anulowania może być lepszą podstawą niż założenie, że każdy klient zwraca niezmieniony AbortError.
Abort odczytu oznacza rezygnację klienta z dalszego oczekiwania. Abort mutacji nie dowodzi, że serwer ją wycofał. Uprawnienia, idempotencja i kontrola współbieżności nadal należą do backendu. Szczegółowe testy kolejności odpowiedzi i ryzyka mutacji opisuje Race conditions w React: gdy starsze żądanie nadpisuje nowszy stan. Tutaj interesuje nas czas życia efektu.
Przekazanie dyżuru jest akcją
Wysłanie notatki przez setShouldSend(true) i efekt obserwujący shouldSend zaciera powód uruchomienia operacji. Przywrócenie stanu lub ponowne zamontowanie komponentu może wtedy wyzwolić coś, co miało wynikać wyłącznie z kliknięcia.
Czytelniejszy będzie jawny handler. To fragment komponentu: pending, draft, settery i sendHandover pochodzą z funkcjonalności; adapter nie jest API frameworka. Zakładamy formularz przypisany do filii, bez zmiany kontekstu do czasu zakończenia operacji.
async function handleHandover() {
if (pending) return;
setPending(true);
setError(null);
try {
await sendHandover({ branchId, note: draft });
setSent(true);
} catch {
setError('Nie udało się wysłać przekazania dyżuru.');
} finally {
setPending(false);
}
}Podepnij go do obsługi submitu, zablokuj natywne wysłanie formularza tam, gdzie to potrzebne, i wyłącz ponowny submit podczas oczekiwania. Boolean poprawia obsługę użytkownika, lecz nie chroni backendu przed duplikatami. Jeżeli operator może zmienić kontekst w trakcie wysyłania, wynik potrzebuje też tożsamości operacji lub osobnego właściciela. Timeout nie oznacza automatycznie, że ponowienie istotnej operacji jest bezpieczne.
Efekt pasuje do utrzymywania subskrypcji zgodnej z wybraną filią. Polecenie wynikające z decyzji „Przekaż dyżur” ma wyraźniejszą przyczynę w handlerze.
Odświeżenie danych nie powinno przejmować szkicu
Liczbę otwartych zgłoszeń bez przypisanej osoby można obliczyć podczas renderu na podstawie bieżącej listy. Osobny stan i efekt aktualizujący tę liczbę dodają kolejny render oraz drugą wartość, którą trzeba utrzymywać w zgodzie z pierwszą. Regułę można zapisać w małej, czystej funkcji:
type DeskIncident = {
status: 'open' | 'resolved';
assigneeId: string | null;
};
export function countUnassigned(incidents: readonly DeskIncident[]) {
return incidents.filter(incident =>
incident.status === 'open' && incident.assigneeId === null,
).length;
}Komponent używa const unassigned = countUnassigned(incidents). Nie ma dodatkowego stanu do synchronizacji ani tablicy zależności do utrzymywania.
Szkic formularza wymaga innej decyzji. Efekt zawierający setDraft(handover.note) z zależnością [handover] wygląda jak synchronizacja. Gdy odświeżenie w tle podmieni obiekt, może skasować tekst wpisany od otwarcia formularza.
Ustal kontrakt: formularz zachowuje własny szkic do wysłania, aktualizacja z zewnątrz go zastępuje czy nowa rewizja serwera pokazuje konflikt? Reset przy zmianie tożsamości rekordu bywa rozsądny, jeśli nawigacja ma ustalone zachowanie dla niezapisanej pracy. Nowa referencja obiektu nie dowodzi, że użytkownik chciał resetu. Tak samo traktuj odpowiedzi autosave: potwierdź zapisaną wersję szkicu, nie zastępując znaków dopisanych później.
Łańcuch efektów ukrywa przebieg procesu
Zmiana filii może pobrać uprawnienia, kolejny efekt ustawić canReview, następny pobrać zgłoszenia, a jeszcze inny wybrać pierwsze i załadować szczegóły. Osobno te fragmenty wyglądają niewinnie. Razem tworzą stany pośrednie i nieaktualną pracę, której źródło trudno ustalić.
Obliczaj canReview, jeśli wynika wprost z uprawnień. Dla rzeczywiście zależnych żądań rozważ jawną sekwencję asynchroniczną z jedną tożsamością kontekstu, warstwę danych routingu lub używany już mechanizm zapytań. Oddzielne subskrypcje mogą pozostać niezależne. Efekty nie mają zakazu wpływania na dane innych efektów; problem pojawia się, gdy kolejność procesu biznesowego można odczytać dopiero po prześledzeniu setterów w całym komponencie.
Nie łącz też socketu, pollingu, tytułu dokumentu i analityki w jednym efekcie tylko dlatego, że wszystkie działają przy widocznym panelu. Dziel kod według zewnętrznej odpowiedzialności i czasu życia, a nie mechanicznie według liczby instrukcji.
Strict Mode ujawnia założenia o cyklu życia
React 19.2 z Strict Mode w korzeniu wykonuje w development dodatkowy cykl setup–cleanup–setup efektu. W używanej generacji Next.js App Router domyślnie włącza Strict Mode. Dla granicy obejmującej tylko poddrzewo obowiązują dodatkowe zastrzeżenia; zachowania korzenia nie należy przypisywać każdej konfiguracji.
Produkcja nie wykonuje tego dodatkowego cyklu z powodu kontroli developerskiej. Nadal występują jednak zmiany zależności, nawigacja i ponowne montowanie. Dwa odczyty w development same w sobie nie dowodzą błędu produkcyjnego. Dwie subskrypcje pozostające aktywne po zakończeniu setupu są znacznie mocniejszym sygnałem.
Wyłączenie Strict Mode może go ukryć. Sprawdzaj, czy setup, cleanup i kolejny setup pozostawiają właściwe zasoby. Nie opieraj diagnozy na historycznych ostrzeżeniach o aktualizacji odmontowanego komponentu: wycieki i błędna własność pracy mają znaczenie niezależnie od tego, czy React wyświetla ostrzeżenie.
Next.js nie przenosi każdego pobrania danych do efektu
Interaktywne przykłady należą do klienckiej części App Routera. Efekty nie wykonują się podczas generowania HTML na serwerze. Server Components, pobieranie danych dla trasy i subskrypcje przeglądarki mają różne zadania. Korzystaj z istniejącej warstwy ładowania tam, gdzie dane mają już właściciela.
Repozytorium ma własnego klienta API i serwisy funkcjonalności, bez skonfigurowanej biblioteki zapytań. Publiczny frontend korzysta też ze statycznego eksportu, co ogranicza funkcje serwerowe wykonywane przy żądaniu. Rada „przenieś to na serwer” musi uwzględniać ten model wdrożenia. W innym projekcie biblioteka zapytań może uporządkować cache, odświeżanie i anulowanie, ale nie zdecyduje, czy aktualizacja w tle ma zastąpić szkic użytkownika.
Hydration mismatch jest osobnym problemem: początkowe reprezentacje serwera i klienta się różnią. Odczyt stanu dostępnego tylko w przeglądarce przez efekt może być właściwy w konkretnym rozwiązaniu. Przenoszenie do efektów dowolnej logiki renderowania nie jest uniwersalną naprawą hydratacji. Podobnie dziesiątki komponentów nie powinny odtwarzać obsługi loadingu, błędów i anulowania, jeśli odpowiada za nią już wspólna warstwa danych.
Odtwórz stary callback bez czekania na sieć
Komponent subskrypcji można sprawdzić przez mały testowy adapter. ManualDeskFeed z tego artykułu zapisuje połączenia i udostępnia callbacki. Wywołanie zapamiętanego callbacku po wypisaniu celowo symuluje pracę będącą wcześniej w kolejce. Nie oznacza, że poprawny transport powinien nadal wysyłać nowe zdarzenia po zakończeniu subskrypcji.
import type { DeskFeed, DeskScope, DeskSnapshot } from './deskContract';
type Connection = {
scope: DeskScope;
receive: (snapshot: DeskSnapshot) => void;
closed: boolean;
};
export class ManualDeskFeed implements DeskFeed {
readonly connections: Connection[] = [];
watch(scope: DeskScope, receive: Connection['receive']) {
const connection = { scope, receive, closed: false };
this.connections.push(connection);
return () => { connection.closed = true; };
}
}Test korzysta z Vitest, React Testing Library, zwykłego środowiska jsdom i matcherów @testing-library/jest-dom/vitest. Importy lokalne wskazują przykłady artykułu. Sprawdzamy widoczny wynik oraz zwolnienie subskrypcji należących do komponentu.
import { act, render, screen } from '@testing-library/react';
import { expect, it } from 'vitest';
import { DeskActivity } from './DeskActivity';
import { ManualDeskFeed } from './ManualDeskFeed';
it('odrzuca callback starej filii i zwalnia obie subskrypcje', () => {
const feed = new ManualDeskFeed();
const view = render(
<DeskActivity branchId="harbour" sessionEpoch={1} feed={feed} />,
);
const harbour = feed.connections[0]!;
act(() => harbour.receive({ branchId: 'harbour', waiting: 7 }));
view.rerender(
<DeskActivity branchId="hill" sessionEpoch={1} feed={feed} />,
);
const hill = feed.connections[1]!;
expect(harbour.closed).toBe(true);
expect(hill.closed).toBe(false);
expect(screen.getByRole('status')).toHaveTextContent('Oczekiwanie na dane');
act(() => hill.receive({ branchId: 'hill', waiting: 2 }));
act(() => harbour.receive({ branchId: 'harbour', waiting: 99 }));
expect(screen.getByRole('heading')).toHaveTextContent('hill');
expect(screen.getByRole('status')).toHaveTextContent('2 oczekujących zgłoszeń');
expect(screen.queryByText('99 oczekujących zgłoszeń')).not.toBeInTheDocument();
view.unmount();
expect(hill.closed).toBe(true);
});Dodaj przypadek wymiany feed bez zmiany filii: sprawdzi on blokadę starego callbacku bez polegania na odmontowaniu przez key. Zmień również sessionEpoch; praca poprzednio zalogowanej osoby musi utracić własność widoku mimo tej samej filii. W korzeniu z StrictMode sprawdzaj jedną aktywną subskrypcję po setupie i zero po odmontowaniu, zamiast globalnie wymagać tylko jednego wywołania setupu.
Dla listenera wyemituj visibilitychange, przerysuj komponent z inną filią i wyemituj zdarzenie ponownie. Każdy powrót do widocznej karty powinien uruchomić dokładnie jeden odczyt aktualnej filii, a po odmontowaniu żaden. Dla pollingu vi.useFakeTimers() i vi.advanceTimersByTimeAsync() w Vitest pozwalają przesuwać czas. Przytrzymaj promise odczytu, aby wykazać brak kolejnego pollu; rozwiąż go, przesuń przerwę, zmień kontekst i dostarcz stary wynik lub błąd. Po każdym teście przywróć rzeczywiste timery. Nie trzeba czekać czasu ściennego.
Każdy test powinien chronić konkretną granicę
Pierwszą warstwę ochrony dobierz do miejsca, w którym można zaobserwować błąd:
Ryzyko Pierwsza użyteczna kontrola
Brak zależności reaktywnej lint Hooks + test zachowania
Decyzja szkicu/reducera test jednostkowy decyzji
Stara subskrypcja komponent z kontrolowanym feedem
Czas życia timera komponent ze sztucznym czasem
Stare dane/błąd/loading komponent z przytrzymanym promise
Anulowanie transportu integracja/kontrakt adaptera
Duplikat skutku biznesowego test funkcjonalny/integracyjny backendu
Nawigacja między filiami wybrany scenariusz przeglądarkowyLint i TypeScript wykrywają część problemów zależności i typów, ale nie dowodzą, że cleanup zwalnia właściwy zasób. Testy adaptera obejmują mapowanie tematów, zmiany uwierzytelnienia, anulowanie i publiczne błędy, przy kontrolowanych odpowiedziach transportu. Test przeglądarkowy może sprawdzić nawigację z opóźnionymi danymi lub zdarzeniami. Dokładną kolejność taniej ustalić w teście komponentu.
Chroń też szkic: wpisz notatkę, dostarcz nowy obiekt serwera i sprawdź ustalone zachowanie szkicu lub konfliktu. Dla osobnych widoków opartych na odczytach sprawdź sukces B przed błędem A oraz zakończenie A, gdy B nadal się ładuje. To odrębne asercje, nawet jeśli używają wspólnego helpera do kontrolowania promise. Szerszą strategię opisuje Jak testować architekturę, błędy i procesy użytkownika w Next.js. Nie trzeba jej tu powielać ani instalować kolejnego runnera dla artykułu.
Najpierw ustal właściciela, potem zmieniaj timing
Zapisz dane pozwalające odtworzyć jeden błąd: identyfikator filii, niesekretną generację sesji, czas setupu i cleanupu, temat subskrypcji oraz identyfikator operacji. Porównaj moment przełączenia filii z zakończeniem starego callbacku. Nie zapisuj poświadczeń, tokenów, chronionych payloadów ani zbędnych danych osobowych.
Pusta tablica zależności potrafi zamrozić niewłaściwe wartości. Wyłączenie exhaustive-deps to ukryje. Dodanie wszystkich świeżo tworzonych obiektów może stale odnawiać połączenie. Memoizacja, isMounted czy timeout mający „poczekać na stan” nie ustalą, do której filii należy aktualizacja. Zacznij od powodu istnienia efektu, potem określ jego wejścia i czas życia.
Przed zaakceptowaniem efektu odpowiedz na pięć pytań:
- Jaki zasób zewnętrzny ma odpowiadać zatwierdzonemu widokowi? Czy wystarczy obliczenie albo handler kliknięcia?
- Jakie wartości identyfikują właściciela, także po zmianie kontekstu logowania?
- Co pozyskuje setup i czy cleanup zwalnia dokładnie ten zasób?
- Czy stara praca nadal może zmienić dane, loading, błąd lub szkic użytkownika?
- Jaki kontrolowany callback, promise lub zegar pokaże to w teście?
W panelu bibliotek naprawa polega na jasnym czasie życia: praca Harbour przestaje należeć do widoku, gdy przejmuje go Hill. Tablica zależności jest częścią tej decyzji. Zwalnianie subskrypcji, obsługa callbacków w kolejce i własność szkicu ją dopełniają.
Dokumentacja zachowania cyklu życia i API platformy:
- https://react.dev/reference/react/useEffect
- https://react.dev/reference/react/StrictMode
- https://react.dev/reference/eslint-plugin-react-hooks/lints/exhaustive-deps
- https://nextjs.org/docs/app/api-reference/config/next-config-js/reactStrictMode
- https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener
