Poprawne czyszczenie efektów ubocznych jest kluczowym aspektem tworzenia niezawodnych aplikacji w React. Podobnie jak astronauci muszą czyścić swoje systemy podtrzymywania życia, aby uniknąć awarii, tak samo my musimy dbać o czyszczenie naszych efektów, aby uniknąć wycieków pamięci, niepotrzebnych obliczeń i innych problemów.
Gdy komponent jest odmontowywany (usuwany z DOM) lub gdy zmieniają się zależności efektu, mogą wystąpić różne problemy, jeśli nie zadbamy o właściwe czyszczenie:
W useEffect, funkcja czyszcząca jest zwracana z funkcji efektu:
1useEffect(() => {
2 // Kod efektu - uruchamiany po renderowaniu
3 console.log('Efekt uruchomiony');
4
5 // Funkcja czyszcząca - uruchamiana przed następnym efektem lub przy odmontowaniu
6 return () => {
7 console.log('Efekt wyczyszczony');
8 };
9}, [dependency]); // Tablica zależnościFunkcja czyszcząca jest uruchamiana w dwóch sytuacjach:
1function SpaceModule({ moduleId }) {
2 useEffect(() => {
3 console.log(`Moduł ${moduleId} został zainicjalizowany`);
4
5 // Ta funkcja zostanie wywołana:
6 // 1. Przed następną inicjalizacją, gdy moduleId się zmieni
7 // 2. Gdy komponent SpaceModule zostanie odmontowany
8 return () => {
9 console.log(`Moduł ${moduleId} został wyłączony`);
10 };
11 }, [moduleId]);
12
13 return <div className="space-module">Moduł kosmiczny: {moduleId}</div>;
14}Niezwolnione interwały i timeouty mogą nadal działać w tle, zużywając zasoby i potencjalnie aktualizując stan nieistniejących już komponentów:
1function Countdown({ seconds, onComplete }) {
2 const [timeLeft, setTimeLeft] = useState(seconds);
3
4 useEffect(() => {
5 // Nie rozpoczynaj odliczania, jeśli już zakończone
6 if (timeLeft <= 0) {
7 onComplete();
8 return;
9 }
10
11 console.log(`Rozpoczynam odliczanie od ${timeLeft}`);
12
13 // Ustawienie interwału
14 const intervalId = setInterval(() => {
15 setTimeLeft(prevTime => {
16 if (prevTime <= 1) {
17 onComplete();
18 return 0;
19 }
20 return prevTime - 1;
21 });
22 }, 1000);
23
24 // Funkcja czyszcząca zatrzymuje interwał
25 return () => {
26 console.log(`Zatrzymuję odliczanie na ${timeLeft}`);
27 clearInterval(intervalId);
28 };
29 }, [timeLeft, onComplete]);
30
31 return <div className="countdown">{timeLeft}</div>;
32}Subskrypcje, które nie są anulowane, mogą nadal otrzymywać dane i powodować aktualizacje stanu:
1function TelemetryMonitor({ shipId }) {
2 const [data, setData] = useState(null);
3
4 useEffect(() => {
5 let isMounted = true;
6 console.log(`Rozpoczynam monitorowanie statku ${shipId}`);
7
8 // Utworzenie subskrypcji do telemetrii
9 const subscription = telemetryService.subscribe(shipId, telemetryData => {
10 // Aktualizuj stan tylko jeśli komponent jest nadal zamontowany
11 if (isMounted) {
12 setData(telemetryData);
13 }
14 });
15
16 // Funkcja czyszcząca anuluje subskrypcję
17 return () => {
18 console.log(`Kończę monitorowanie statku ${shipId}`);
19 isMounted = false;
20 subscription.unsubscribe();
21 };
22 }, [shipId]);
23
24 if (!data) return <div>Oczekiwanie na dane telemetryczne...</div>;
25
26 return (
27 <div className="telemetry">
28 <h3>Telemetria statku: {shipId}</h3>
29 <p>Poziom tlenu: {data.oxygen}%</p>
30 <p>Temperatura: {data.temperature}°C</p>
31 <p>Prędkość: {data.velocity} km/h</p>
32 </div>
33 );
34}Nasłuchiwacze zdarzeń, które nie są usuwane, mogą prowadzić do wycieków pamięci:
1function KeyboardShortcuts({ onEscape }) {
2 useEffect(() => {
3 console.log('Instaluję nasłuchiwacz klawiatury');
4
5 // Funkcja obsługująca naciśnięcie klawisza
6 const handleKeyDown = (event) => {
7 if (event.key === 'Escape') {
8 onEscape();
9 }
10 };
11
12 // Dodanie nasłuchiwacza do dokumentu
13 document.addEventListener('keydown', handleKeyDown);
14
15 // Funkcja czyszcząca usuwa nasłuchiwacz
16 return () => {
17 console.log('Usuwam nasłuchiwacz klawiatury');
18 document.removeEventListener('keydown', handleKeyDown);
19 };
20 }, [onEscape]);
21
22 return null; // Ten komponent nie renderuje niczego widocznego
23}Połączenia, które pozostają otwarte, mogą zużywać zasoby i powodować wycieki:
1function MissionControlSocket({ missionId, onMessage }) {
2 const [status, setStatus] = useState('disconnected');
3
4 useEffect(() => {
5 let socket = null;
6
7 // Funkcja inicjalizująca połączenie
8 const connect = () => {
9 console.log(`Łączenie z misją ${missionId}`);
10 setStatus('connecting');
11
12 // Utworzenie WebSocketa
13 socket = new WebSocket(`wss://mission-control.space/mission/${missionId}`);
14
15 socket.onopen = () => {
16 console.log(`Połączono z misją ${missionId}`);
17 setStatus('connected');
18 };
19
20 socket.onmessage = (event) => {
21 const data = JSON.parse(event.data);
22 onMessage(data);
23 };
24
25 socket.onerror = (error) => {
26 console.error(`Błąd połączenia WebSocket: ${error.message}`);
27 setStatus('error');
28 };
29
30 socket.onclose = () => {
31 console.log(`Rozłączono z misją ${missionId}`);
32 setStatus('disconnected');
33 };
34 };
35
36 // Inicjalizacja połączenia
37 connect();
38
39 // Funkcja czyszcząca zamyka połączenie
40 return () => {
41 console.log(`Zamykanie połączenia z misją ${missionId}`);
42 if (socket) {
43 socket.close();
44 }
45 };
46 }, [missionId, onMessage]);
47
48 return (
49 <div className="socket-status">
50 Status połączenia: {status}
51 </div>
52 );
53}Anulowanie trwających żądań fetch zapobiega aktualizacjom stanu po odmontowaniu:
1function PlanetInfo({ planetId }) {
2 const [planet, setPlanet] = useState(null);
3 const [loading, setLoading] = useState(true);
4 const [error, setError] = useState(null);
5
6 useEffect(() => {
7 // Utworzenie kontrolera do anulowania żądania
8 const abortController = new AbortController();
9 const signal = abortController.signal;
10
11 async function fetchPlanetData() {
12 try {
13 setLoading(true);
14
15 console.log(`Pobieranie danych planety ${planetId}`);
16 const response = await fetch(`https://api.space.com/planets/${planetId}`, {
17 signal // przekazanie sygnału do fetch
18 });
19
20 if (!response.ok) {
21 throw new Error(`HTTP Error: ${response.status}`);
22 }
23
24 const data = await response.json();
25 setPlanet(data);
26 setError(null);
27 } catch (err) {
28 // Ignoruj błędy anulowania
29 if (err.name !== 'AbortError') {
30 console.error(`Błąd pobierania danych: ${err.message}`);
31 setError(`Nie udało się pobrać danych planety: ${err.message}`);
32 }
33 } finally {
34 setLoading(false);
35 }
36 }
37
38 fetchPlanetData();
39
40 // Funkcja czyszcząca anuluje trwające żądania
41 return () => {
42 console.log(`Anulowanie żądania dla planety ${planetId}`);
43 abortController.abort();
44 };
45 }, [planetId]);
46
47 if (loading) return <div>Ładowanie danych planety...</div>;
48 if (error) return <div>Błąd: {error}</div>;
49 if (!planet) return <div>Brak danych planety</div>;
50
51 return (
52 <div className="planet-card">
53 <h2>{planet.name}</h2>
54 <p>Typ: {planet.type}</p>
55 <p>Średnica: {planet.diameter} km</p>
56 <p>Odległość od Słońca: {planet.distanceFromSun} mln km</p>
57 </div>
58 );
59}Chociaż użycie AbortController jest preferowanym sposobem anulowania żądań fetch, w przypadkach, gdy nie możemy łatwo anulować operacji asynchronicznej, możemy użyć flagi
isMounted:1function AsteroidTracker({ asteroidId }) {
2 const [data, setData] = useState(null);
3
4 useEffect(() => {
5 let isMounted = true; // Flaga informująca, czy komponent jest zamontowany
6
7 async function fetchAsteroidData() {
8 const response = await fetch(`https://api.nasa.gov/neo/rest/v1/neo/${asteroidId}`);
9 const asteroidData = await response.json();
10
11 // Aktualizuj stan tylko jeśli komponent jest nadal zamontowany
12 if (isMounted) {
13 setData(asteroidData);
14 }
15 }
16
17 fetchAsteroidData();
18
19 // Funkcja czyszcząca zmienia flagę
20 return () => {
21 isMounted = false;
22 };
23 }, [asteroidId]);
24
25 // Renderowanie...
26}Gdy synchronizujemy stan React z zewnętrznym składowym stanu, ważne jest, aby przywrócić poprzedni stan podczas czyszczenia:
1function ThemeSwitcher({ theme }) {
2 useEffect(() => {
3 // Zapisanie aktualnego motywu
4 const previousTheme = document.body.dataset.theme;
5
6 // Ustawienie nowego motywu
7 document.body.dataset.theme = theme;
8 console.log(`Zmieniam motyw na: ${theme}`);
9
10 // Przywrócenie poprzedniego motywu podczas czyszczenia
11 return () => {
12 console.log(`Przywracam poprzedni motyw: ${previousTheme || 'default'}`);
13 document.body.dataset.theme = previousTheme || 'default';
14 };
15 }, [theme]);
16
17 return (
18 <div className="theme-info">
19 Aktualny motyw: {theme}
20 </div>
21 );
22}Gdy wiele komponentów współdzieli zasoby, należy użyć systemu zliczania referencji:
1// Singleton do zarządzania współdzielonymi zasobami
2class SharedResourceManager {
3 constructor() {
4 this.resources = new Map();
5 this.refCounts = new Map();
6 }
7
8 // Pozyskanie zasobu (zwiększenie licznika referencji)
9 acquire(resourceId, creator) {
10 if (!this.resources.has(resourceId)) {
11 // Tworzenie zasobu, jeśli nie istnieje
12 this.resources.set(resourceId, creator());
13 this.refCounts.set(resourceId, 0);
14 }
15
16 // Zwiększenie licznika referencji
17 this.refCounts.set(resourceId, this.refCounts.get(resourceId) + 1);
18 return this.resources.get(resourceId);
19 }
20
21 // Zwolnienie zasobu (zmniejszenie licznika referencji)
22 release(resourceId) {
23 if (!this.resources.has(resourceId)) return;
24
25 // Zmniejszenie licznika referencji
26 const newCount = this.refCounts.get(resourceId) - 1;
27 this.refCounts.set(resourceId, newCount);
28
29 // Usunięcie zasobu, gdy nie ma już referencji
30 if (newCount <= 0) {
31 const resource = this.resources.get(resourceId);
32 if (resource.dispose && typeof resource.dispose === 'function') {
33 resource.dispose();
34 }
35 this.resources.delete(resourceId);
36 this.refCounts.delete(resourceId);
37 }
38 }
39}
40
41// Singleton do użycia w całej aplikacji
42export const sharedResources = new SharedResourceManager();
43
44// Hook do użycia współdzielonego zasobu
45function useSharedResource(resourceId, createResource) {
46 useEffect(() => {
47 // Pozyskanie zasobu
48 const resource = sharedResources.acquire(resourceId, createResource);
49 console.log(`Zasób ${resourceId} pozyskany, liczba użytkowników: ${sharedResources.refCounts.get(resourceId)}`);
50
51 // Zwolnienie zasobu przy czyszczeniu
52 return () => {
53 sharedResources.release(resourceId);
54 console.log(`Zasób ${resourceId} zwolniony`);
55 };
56 }, [resourceId]);
57
58 return sharedResources.resources.get(resourceId);
59}
60
61// Przykład użycia
62function SpaceshipScanner() {
63 // Ten zasób będzie współdzielony między wszystkimi instancjami komponentu
64 const scanner = useSharedResource('advanced-scanner', () => {
65 console.log('Inicjalizacja skanera (kosztowna operacja)');
66 return {
67 scan: (target) => console.log(`Skanowanie ${target}...`),
68 dispose: () => console.log('Wyłączanie skanera')
69 };
70 });
71
72 return (
73 <button onClick={() => scanner.scan('Mars')}>
74 Skanuj Mars
75 </button>
76 );
77}Problem: Niepoprawne zatrzymanie timera może prowadzić do przepełnienia stosu:
1// ŹLE: Funkcja czyszcząca nie zatrzymuje timeoutu prawidłowo
2useEffect(() => {
3 function tick() {
4 setCount(count + 1);
5 setTimeout(tick, 1000);
6 }
7
8 tick();
9
10 return () => {
11 // Problem: nie mamy odniesienia do aktualnego timeout ID
12 };
13}, [count]); // 🚨 Zależność powoduje ponowne uruchomienie efektuRozwiązanie: Przechowuj aktualny identyfikator timeout:
1// DOBRZE: Zapisujemy ID timeoutu, aby móc go anulować
2useEffect(() => {
3 let timeoutId = null;
4
5 function tick() {
6 setCount(prevCount => prevCount + 1);
7 timeoutId = setTimeout(tick, 1000);
8 }
9
10 timeoutId = setTimeout(tick, 1000);
11
12 return () => {
13 clearTimeout(timeoutId);
14 };
15}, []); // Brak zależności - uruchamiamy tylko razProblem: Czasami kolejność czyszczenia ma znaczenie, szczególnie gdy mamy kilka zagnieżdżonych efektów:
1// Problematyczna kolejność czyszczenia
2useEffect(() => {
3 // Efekt zewnętrzny
4 const resource = initializeResource();
5
6 useEffect(() => {
7 // Efekt wewnętrzny korzystający z zasobu
8 resource.use();
9
10 return () => {
11 // To czyszczenie może być wywołane po zwolnieniu zasobu
12 resource.stopUsing();
13 };
14 }, [someCondition]);
15
16 return () => {
17 // Ten zasób może być już używany przez wewnętrzny efekt
18 resource.dispose();
19 };
20}, []);Rozwiązanie: Używaj oddzielnych, niezależnych efektów:
1// Lepsze podejście
2useEffect(() => {
3 // Efekt inicjalizujący zasób
4 const resource = initializeResource();
5
6 return () => {
7 // Czyszczenie zasobu
8 resource.dispose();
9 };
10}, []);
11
12useEffect(() => {
13 // Efekt korzystający z zasobu (używaj useRef lub kontekstu)
14 if (resourceRef.current) {
15 resourceRef.current.use();
16
17 return () => {
18 if (resourceRef.current) {
19 resourceRef.current.stopUsing();
20 }
21 };
22 }
23}, [someCondition]);Problem: Podczas czyszczenia możemy uzyskać dostęp do nieaktualnego stanu:
1function SpaceshipControl({ shipId }) {
2 const [power, setPower] = useState(100);
3
4 useEffect(() => {
5 // Inicjalizacja kontroli statku
6 const ship = spaceshipAPI.connect(shipId);
7 ship.setPower(power);
8
9 return () => {
10 // 🚨 Problem: power może być nieaktualne podczas czyszczenia
11 console.log(`Odłączanie od statku przy mocy: ${power}`);
12 ship.disconnect();
13 };
14 }, [shipId]); // Brak zależności od power
15
16 return (
17 <div>
18 <p>Moc silników: {power}%</p>
19 <button onClick={() => setPower(power - 10)}>Zmniejsz moc</button>
20 </div>
21 );
22}Rozwiązanie: Używaj referencji do przechowywania aktualnych wartości stanu:
1function SpaceshipControl({ shipId }) {
2 const [power, setPower] = useState(100);
3 const powerRef = useRef(power);
4
5 // Aktualizacja referencji przy każdej zmianie stanu
6 useEffect(() => {
7 powerRef.current = power;
8 }, [power]);
9
10 useEffect(() => {
11 // Inicjalizacja kontroli statku
12 const ship = spaceshipAPI.connect(shipId);
13 ship.setPower(power);
14
15 return () => {
16 // ✅ Używamy aktualnej wartości z referencji
17 console.log(`Odłączanie od statku przy mocy: ${powerRef.current}%`);
18 ship.disconnect();
19 };
20 }, [shipId, power]);
21
22 return (
23 <div>
24 <p>Moc silników: {power}%</p>
25 <button onClick={() => setPower(power - 10)}>Zmniejsz moc</button>
26 </div>
27 );
28}Aby upewnić się, że efekty są poprawnie czyszczone, warto pisać testy sprawdzające to zachowanie:
1// Przykład testu dla komponentu z czyszczeniem efektu
2import { render, act, screen } from '@testing-library/react';
3import { useState } from 'react';
4
5// Prosty komponent z timerem do testowania
6function TimerComponent() {
7 const [count, setCount] = useState(0);
8
9 useEffect(() => {
10 const interval = setInterval(() => {
11 setCount(c => c + 1);
12 }, 100);
13
14 return () => clearInterval(interval);
15 }, []);
16
17 return <div data-testid="count">{count}</div>;
18}
19
20// Test czyszczenia efektu
21test('effect cleanup stops the interval', async () => {
22 jest.useFakeTimers();
23
24 const { unmount } = render(<TimerComponent />);
25
26 // Pozwól na kilka tyknięć timera
27 act(() => {
28 jest.advanceTimersByTime(250);
29 });
30
31 const countBefore = Number(screen.getByTestId('count').textContent);
32
33 // Odmontuj komponent (powinno to wyczyścić efekt i zatrzymać timer)
34 unmount();
35
36 // Przesuń zegar do przodu jeszcze raz
37 act(() => {
38 jest.advanceTimersByTime(250);
39 });
40
41 // Ponieważ komponent jest odmontowany i timer zatrzymany,
42 // możemy tylko sprawdzić, czy licznik nie wzrósł za bardzo przed odmontowaniem
43 expect(countBefore).toBeGreaterThanOrEqual(2);
44 expect(countBefore).toBeLessThanOrEqual(3);
45
46 jest.useRealTimers();
47});Zawsze czyść efekty, które tworzą subskrypcje - timery, nasłuchiwacze zdarzeń, połączenia WebSocket, itp.
Używaj AbortController do anulowania żądań fetch - jest to standardowy mechanizm do anulowania operacji asynchronicznych.
Utrzymuj flagę montowania - dla operacji asynchronicznych, których nie można anulować, używaj flagi
isMounted i sprawdzaj ją przed aktualizacją stanu.Przechowuj referencje do zasobów - zmienne lokalne w efekcie przestają istnieć po jego wyczyszczeniu, więc używaj
useRef do przechowywania odniesień do zasobów.Unikaj zbyt skomplikowanych efektów - efekt, który robi zbyt wiele rzeczy, będzie trudniejszy do poprawnego wyczyszczenia. Rozważ podzielenie go na mniejsze, bardziej skoncentrowane efekty.
Unikaj asynchronicznych funkcji czyszczących - funkcje czyszczące powinny być synchroniczne, aby React mógł zagwarantować ich wykonanie we właściwej kolejności.
Testuj czyszczenie efektów - szczególnie ważne jest testowanie odmontowywania komponentów, które tworzą zewnętrzne subskrypcje.
Odpowiednie czyszczenie efektów nie tylko zapobiega wyciekom pamięci, ale może również znacząco wpłynąć na wydajność aplikacji:
Mniejsze zużycie pamięci - przez zwalnianie nieużywanych zasobów
Mniej niepotrzebnych obliczeń - przez anulowanie operacji, które nie są już potrzebne
Redukcja liczby ponownych renderowań - przez unikanie niepotrzebnych aktualizacji stanu
Szybsze przeładowania strony - przez zapewnienie, że wszystkie zasoby są prawidłowo zwalniane
Czyszczenie efektów jest często pomijanym, ale kluczowym aspektem pracy z React. Podobnie jak astronauci muszą dbać o swoje systemy podtrzymywania życia, tak programiści muszą dbać o czystość swoich efektów ubocznych.
Dobrze napisane funkcje czyszczące:
Pamiętaj, że każdy efekt, który tworzy połączenie, subskrypcję lub rezerwuje zasoby, powinien je również zwolnić podczas czyszczenia.