W poprzednim module poznaliśmy podstawy efektów ubocznych z hookiem useEffect. Teraz zagłębimy się w jeden z najważniejszych przypadków użycia efektów: synchronizację komponentów React z zewnętrznymi systemami i bibliotekami.
Komponenty React istnieją w swoim reaktywnym środowisku, ale często muszą współpracować z niezależnymi systemami, które nie są świadome cyklu życia React ani jego modelu renderowania. Możesz wyobrazić sobie to jako mostek między statkiem kosmicznym React a innymi systemami - komunikacja musi być dwustronna i niezawodna.
Przykładami takich zewnętrznych systemów są:
Podstawowy wzorzec synchronizacji z zewnętrznym systemem wygląda następująco:
1function ExternalSystemComponent({ config }) {
2 useEffect(() => {
3 // 1. Inicjalizacja
4 const externalInstance = new ExternalSystem(config);
5 externalInstance.initialize();
6
7 // 2. Aktualizacja (opcjonalnie)
8 externalInstance.update(config);
9
10 // 3. Sprzątanie
11 return () => {
12 externalInstance.cleanup();
13 };
14 }, [config]); // Ponowna synchronizacja, gdy zmienia się konfiguracja
15
16 return <div className="external-system-container" />;
17}Leaflet to popularna biblioteka do interaktywnych map, która działa niezależnie od React. Oto przykład integracji:
1import React, { useEffect, useRef } from 'react';
2import L from 'leaflet';
3import 'leaflet/dist/leaflet.css';
4
5function MarsExplorationMap({ missionCoordinates, roverPosition, zoom = 5 }) {
6 // Ref do kontenera mapy
7 const mapContainerRef = useRef(null);
8 // Ref do instancji mapy (aby uniknąć ponownego tworzenia)
9 const mapInstanceRef = useRef(null);
10 // Ref do markera łazika (aby aktualizować tylko jego pozycję)
11 const roverMarkerRef = useRef(null);
12
13 // Inicjalizacja mapy
14 useEffect(() => {
15 if (!mapInstanceRef.current && mapContainerRef.current) {
16 // Tworzenie instancji mapy tylko raz
17 const mapInstance = L.map(mapContainerRef.current).setView(
18 [missionCoordinates.lat, missionCoordinates.lng],
19 zoom
20 );
21
22 // Dodanie warstwy mapy
23 L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
24 attribution: '© OpenStreetMap contributors'
25 }).addTo(mapInstance);
26
27 // Zapisanie instancji mapy w referencji
28 mapInstanceRef.current = mapInstance;
29
30 // Dodanie markera łazika
31 roverMarkerRef.current = L.marker([roverPosition.lat, roverPosition.lng])
32 .addTo(mapInstance)
33 .bindPopup('Łazik marsjański');
34 }
35
36 // Funkcja czyszcząca
37 return () => {
38 if (mapInstanceRef.current) {
39 // Usunięcie mapy przy odmontowaniu komponentu
40 mapInstanceRef.current.remove();
41 mapInstanceRef.current = null;
42 roverMarkerRef.current = null;
43 }
44 };
45 }, []); // Pusta tablica zależności - inicjalizacja tylko raz
46
47 // Aktualizacja pozycji łazika
48 useEffect(() => {
49 if (roverMarkerRef.current && roverPosition) {
50 // Aktualizacja pozycji istniejącego markera
51 roverMarkerRef.current.setLatLng([roverPosition.lat, roverPosition.lng]);
52 }
53 }, [roverPosition]); // Aktualizuj tylko gdy zmienia się pozycja łazika
54
55 // Aktualizacja poziomu przybliżenia
56 useEffect(() => {
57 if (mapInstanceRef.current) {
58 mapInstanceRef.current.setZoom(zoom);
59 }
60 }, [zoom]); // Aktualizuj tylko gdy zmienia się zoom
61
62 return <div ref={mapContainerRef} style={{ height: '500px', width: '100%' }} />;
63}W powyższym przykładzie:
Chart.js to popularna biblioteka do tworzenia interaktywnych wykresów:
1import React, { useEffect, useRef } from 'react';
2import Chart from 'chart.js/auto';
3
4function MissionResourcesChart({ resources, title }) {
5 const chartRef = useRef(null);
6 const chartInstanceRef = useRef(null);
7
8 useEffect(() => {
9 // Upewniamy się, że mamy dostęp do elementu canvas
10 if (!chartRef.current) return;
11
12 // Pobierz kontekst canvas
13 const ctx = chartRef.current.getContext('2d');
14
15 // Zniszcz poprzednią instancję wykresu, jeśli istnieje
16 if (chartInstanceRef.current) {
17 chartInstanceRef.current.destroy();
18 }
19
20 // Utwórz nową instancję wykresu
21 chartInstanceRef.current = new Chart(ctx, {
22 type: 'bar',
23 data: {
24 labels: resources.map(resource => resource.name),
25 datasets: [{
26 label: title || 'Zasoby misji',
27 data: resources.map(resource => resource.level),
28 backgroundColor: resources.map(resource => {
29 // Kolorowanie słupków w zależności od poziomu zasobów
30 if (resource.level < 20) return 'rgba(255, 0, 0, 0.7)'; // Krytyczny
31 if (resource.level < 50) return 'rgba(255, 165, 0, 0.7)'; // Ostrzeżenie
32 return 'rgba(0, 128, 0, 0.7)'; // OK
33 }),
34 borderColor: 'rgba(0, 0, 0, 0.1)',
35 borderWidth: 1
36 }]
37 },
38 options: {
39 responsive: true,
40 scales: {
41 y: {
42 beginAtZero: true,
43 max: 100
44 }
45 }
46 }
47 });
48
49 // Sprzątanie
50 return () => {
51 if (chartInstanceRef.current) {
52 chartInstanceRef.current.destroy();
53 chartInstanceRef.current = null;
54 }
55 };
56 }, [resources, title]); // Ponowne tworzenie wykresu przy zmianie danych lub tytułu
57
58 return <canvas ref={chartRef} />;
59}Integracja z Web Sockets umożliwia komunikację czasu rzeczywistego:
1import React, { useEffect, useState } from 'react';
2
3function MissionControlSocket({ missionId, onMessage }) {
4 const [status, setStatus] = useState('disconnected');
5 const [error, setError] = useState(null);
6
7 useEffect(() => {
8 // Inicjalizacja socketu
9 const socket = new WebSocket(`wss://mission-control.example.com/ws/${missionId}`);
10
11 // Konfiguracja zdarzeń
12 socket.onopen = () => {
13 setStatus('connected');
14 console.log(`Połączono z misją ${missionId}`);
15 };
16
17 socket.onmessage = (event) => {
18 const data = JSON.parse(event.data);
19 onMessage(data);
20 };
21
22 socket.onerror = (err) => {
23 setStatus('error');
24 setError('Błąd połączenia z centrum kontroli misji');
25 console.error('WebSocket error:', err);
26 };
27
28 socket.onclose = () => {
29 setStatus('disconnected');
30 console.log('Połączenie zamknięte');
31 };
32
33 // Funkcja czyszcząca
34 return () => {
35 // Zamknięcie połączenia przy odmontowaniu
36 if (socket.readyState === WebSocket.OPEN) {
37 socket.close();
38 }
39 };
40 }, [missionId, onMessage]); // Ponowne połączenie przy zmianie missionId
41
42 return (
43 <div className="mission-socket">
44 <div className={`connection-status ${status}`}>
45 Status połączenia: {status}
46 </div>
47 {error && <div className="connection-error">{error}</div>}
48 </div>
49 );
50}Unikaj niepotrzebnego inicjalizowania zewnętrznych systemów, gdy tylko część konfiguracji się zmienia. Zamiast tego:
1// ŹLE - pojedynczy efekt reinikcjalizuje całość
2useEffect(() => {
3 const chart = new ChartLibrary(config);
4 return () => chart.destroy();
5}, [config]); // Każda zmiana w config spowoduje reinicjalizację
6
7// DOBRZE - oddzielna inicjalizacja i aktualizacja
8// Ref do przechowywania instancji
9const chartRef = useRef(null);
10
11// Inicjalizacja tylko raz
12useEffect(() => {
13 chartRef.current = new ChartLibrary(containerRef.current);
14 return () => chartRef.current.destroy();
15}, []);
16
17// Aktualizacja konfiguracji bez reinicjalizacji
18useEffect(() => {
19 if (chartRef.current) {
20 chartRef.current.updateConfig(config);
21 }
22}, [config]);Przy pracy z zewnętrznymi źródłami danych, które używają subskrypcji:
1function TelemetryMonitor({ satelliteId, dataType }) {
2 const [data, setData] = useState(null);
3
4 useEffect(() => {
5 // Utworzenie subskrypcji
6 const subscription = telemetryAPI.subscribe(
7 satelliteId,
8 dataType,
9 (newData) => setData(newData)
10 );
11
12 // Czyszczenie
13 return () => subscription.unsubscribe();
14 }, [satelliteId, dataType]); // Ponowna subskrypcja gdy zmieni się ID lub typ danych
15
16 return <div>{/* Renderowanie danych */}</div>;
17}Dla ciężkich bibliotek zewnętrznych, możesz opóźnić ich inicjalizację do momentu, gdy są faktycznie potrzebne:
1function HeavyChart({ data, shouldRender }) {
2 const [chartInstance, setChartInstance] = useState(null);
3 const containerRef = useRef(null);
4
5 // Leniwa inicjalizacja
6 useEffect(() => {
7 if (!shouldRender) {
8 // Nie inicjalizuj jeśli nie powinniśmy jeszcze renderować
9 return;
10 }
11
12 // Dynamiczny import ciężkiej biblioteki
13 import('heavy-chart-library').then(ChartLib => {
14 const instance = new ChartLib.Chart(containerRef.current, data);
15 setChartInstance(instance);
16
17 return () => {
18 instance.destroy();
19 };
20 });
21 }, [shouldRender]); // Uruchom tylko gdy shouldRender zmienia się na true
22
23 // Aktualizacja danych
24 useEffect(() => {
25 if (chartInstance && data) {
26 chartInstance.updateData(data);
27 }
28 }, [chartInstance, data]);
29
30 return <div ref={containerRef} />;
31}Aby poprawić ponowne użycie kodu i enkapsulację logiki synchronizacji, warto tworzyć własne hooki.
1function useLeafletMap(containerRef, initialConfig) {
2 const mapRef = useRef(null);
3 const [isReady, setIsReady] = useState(false);
4
5 // Inicjalizacja
6 useEffect(() => {
7 if (!containerRef.current || mapRef.current) return;
8
9 const map = L.map(containerRef.current, initialConfig);
10 L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
11
12 mapRef.current = map;
13 setIsReady(true);
14
15 return () => {
16 map.remove();
17 mapRef.current = null;
18 setIsReady(false);
19 };
20 }, [containerRef, initialConfig]);
21
22 // Metody do manipulacji mapą
23 const setView = useCallback((center, zoom) => {
24 if (mapRef.current) {
25 mapRef.current.setView(center, zoom);
26 }
27 }, []);
28
29 const addMarker = useCallback((position, options = {}) => {
30 if (!mapRef.current) return null;
31
32 const marker = L.marker(position, options).addTo(mapRef.current);
33 return marker;
34 }, []);
35
36 return {
37 map: mapRef.current,
38 isReady,
39 setView,
40 addMarker
41 };
42}
43
44// Użycie custom hooka
45function MarsMap() {
46 const containerRef = useRef(null);
47 const { map, isReady, setView, addMarker } = useLeafletMap(
48 containerRef,
49 { center: [24.7136, 46.6753], zoom: 5 }
50 );
51
52 // Dodanie markera po załadowaniu mapy
53 useEffect(() => {
54 if (isReady) {
55 const marker = addMarker([24.7136, 46.6753], { title: 'Baza Alfa' });
56 marker.bindPopup('Baza Alfa - Główna kolonia na Marsie').openPopup();
57 }
58 }, [isReady, addMarker]);
59
60 return (
61 <div>
62 <h2>Mapa kolonii marsjańskiej</h2>
63 <div ref={containerRef} style={{ height: '500px' }} />
64 {!isReady && <div>Ładowanie mapy...</div>}
65 </div>
66 );
67}1function useLocalStorage(key, initialValue) {
2 // Funkcja inicjalizująca stan - uruchamiana tylko raz
3 const [storedValue, setStoredValue] = useState(() => {
4 try {
5 // Sprawdzenie czy localStorage jest dostępny
6 if (typeof window === 'undefined') {
7 return initialValue;
8 }
9
10 // Próba pobrania wartości z localStorage
11 const item = window.localStorage.getItem(key);
12 return item ? JSON.parse(item) : initialValue;
13 } catch (error) {
14 console.error('Błąd odczytu z localStorage:', error);
15 return initialValue;
16 }
17 });
18
19 // Funkcja aktualizująca wartość w localStorage
20 const setValue = useCallback((value) => {
21 try {
22 // Obsługa funkcji jako wartości (jak w setState)
23 const valueToStore = value instanceof Function ? value(storedValue) : value;
24
25 // Aktualizacja stanu React
26 setStoredValue(valueToStore);
27
28 // Aktualizacja localStorage
29 if (typeof window !== 'undefined') {
30 window.localStorage.setItem(key, JSON.stringify(valueToStore));
31 }
32 } catch (error) {
33 console.error('Błąd zapisu do localStorage:', error);
34 }
35 }, [key, storedValue]);
36
37 // Synchronizacja z localStorage przy zmianie klucza
38 useEffect(() => {
39 // Pobranie aktualnej wartości dla nowego klucza
40 try {
41 if (typeof window === 'undefined') return;
42
43 const item = window.localStorage.getItem(key);
44 setStoredValue(item ? JSON.parse(item) : initialValue);
45 } catch (error) {
46 console.error('Błąd synchronizacji z localStorage:', error);
47 }
48 }, [key, initialValue]);
49
50 return [storedValue, setValue];
51}
52
53// Użycie custom hooka
54function AstronautSettings() {
55 const [settings, setSettings] = useLocalStorage('astronaut-settings', {
56 oxygenAlert: 20,
57 darkMode: false,
58 notifications: true
59 });
60
61 const toggleDarkMode = () => {
62 setSettings(prev => ({
63 ...prev,
64 darkMode: !prev.darkMode
65 }));
66 };
67
68 return (
69 <div className={settings.darkMode ? 'dark-theme' : 'light-theme'}>
70 <h2>Ustawienia astronauty</h2>
71 <button onClick={toggleDarkMode}>
72 Przełącz na tryb {settings.darkMode ? 'jasny' : 'ciemny'}
73 </button>
74 <div>
75 Powiadomienie o poziomie tlenu: {settings.oxygenAlert}%
76 <input
77 type="range"
78 min="5"
79 max="50"
80 value={settings.oxygenAlert}
81 onChange={e => setSettings({
82 ...settings,
83 oxygenAlert: Number(e.target.value)
84 })}
85 />
86 </div>
87 <label>
88 <input
89 type="checkbox"
90 checked={settings.notifications}
91 onChange={() => setSettings(prev => ({
92 ...prev,
93 notifications: !prev.notifications
94 }))}
95 />
96 Włącz powiadomienia
97 </label>
98 </div>
99 );
100}Testowanie komponentów, które synchronizują się z zewnętrznymi systemami, wymaga specjalnego podejścia:
Przykład testu dla komponentu z WebSocket:
1import { render, act, screen, waitFor } from '@testing-library/react';
2import MissionControlSocket from './MissionControlSocket';
3
4// Mock dla WebSocket
5class MockWebSocket {
6 constructor(url) {
7 this.url = url;
8 this.readyState = WebSocket.CONNECTING;
9
10 // Automatyczne "połączenie" po utworzeniu
11 setTimeout(() => {
12 this.readyState = WebSocket.OPEN;
13 this.onopen && this.onopen();
14 }, 0);
15 }
16
17 // Metody do symulowania zdarzeń z zewnątrz
18 simulateMessage(data) {
19 this.onmessage && this.onmessage({ data: JSON.stringify(data) });
20 }
21
22 simulateError() {
23 this.onerror && this.onerror(new Error('Test error'));
24 }
25
26 simulateClose() {
27 this.readyState = WebSocket.CLOSED;
28 this.onclose && this.onclose();
29 }
30
31 close() {
32 this.simulateClose();
33 }
34}
35
36// Podmiana globalnej klasy WebSocket
37global.WebSocket = MockWebSocket;
38
39describe('MissionControlSocket', () => {
40 test('should connect to WebSocket and handle messages', async () => {
41 const mockOnMessage = jest.fn();
42
43 render(
44 <MissionControlSocket
45 missionId="apollo11"
46 onMessage={mockOnMessage}
47 />
48 );
49
50 // Czekaj na połączenie
51 await waitFor(() => {
52 expect(screen.getByText(/status połączenia: connected/i)).toBeInTheDocument();
53 });
54
55 // Symulacja otrzymania wiadomości
56 const testData = { type: 'telemetry', oxygen: 95, fuel: 76 };
57 act(() => {
58 // Dostęp do instancji WebSocket przez monkeypatching
59 const socket = global.WebSocket.instances[0];
60 socket.simulateMessage(testData);
61 });
62
63 // Sprawdzenie czy callback został wywołany z odpowiednimi danymi
64 expect(mockOnMessage).toHaveBeenCalledWith(testData);
65
66 // Symulacja błędu
67 act(() => {
68 const socket = global.WebSocket.instances[0];
69 socket.simulateError();
70 });
71
72 // Sprawdzenie czy status błędu jest widoczny
73 expect(screen.getByText(/status połączenia: error/i)).toBeInTheDocument();
74 expect(screen.getByText(/błąd połączenia/i)).toBeInTheDocument();
75 });
76
77 test('should clean up WebSocket connection on unmount', () => {
78 const { unmount } = render(
79 <MissionControlSocket missionId="apollo11" onMessage={() => {}} />
80 );
81
82 // Pobierz instancję WebSocket
83 const socket = global.WebSocket.instances[0];
84 const closeSpy = jest.spyOn(socket, 'close');
85
86 // Odmontowanie komponentu
87 unmount();
88
89 // Sprawdzenie czy socket został zamknięty
90 expect(closeSpy).toHaveBeenCalled();
91 });
92});Synchronizacja z zewnętrznymi systemami to jedna z najważniejszych funkcji hooka useEffect. Umożliwia wykorzystanie istniejących bibliotek i API w ekosystemie React, zachowując reaktywny model aktualizacji.
Najważniejsze zasady:
Pamiętaj, że głównym celem synchronizacji jest utrzymanie spójności między reaktywnym światem React a zewnętrznymi systemami, które nie wiedzą nic o cyklu życia komponentów. Dzięki prawidłowej synchronizacji, Twoja aplikacja będzie działać płynnie i efektywnie, łącząc różne technologie w jedną, spójną całość.