In the previous module, we learned the basics of side effects with the useEffect hook. Now we'll dive deeper into one of the most important use cases for effects: synchronizing React components with external systems and libraries.
React components exist in their own reactive environment, but they often need to interact with independent systems that are unaware of React's lifecycle or rendering model. You can think of this as a bridge between the React spaceship and other systems - communication must be bidirectional and reliable.
Examples of such external systems include:
The basic pattern for synchronization with an external system looks as follows:
1function ExternalSystemComponent({ config }) {
2 useEffect(() => {
3 // 1. Initialization
4 const externalInstance = new ExternalSystem(config);
5 externalInstance.initialize();
6
7 // 2. Update (optional)
8 externalInstance.update(config);
9
10 // 3. Cleanup
11 return () => {
12 externalInstance.cleanup();
13 };
14 }, [config]); // Re-synchronize when config changes
15
16 return <div className="external-system-container" />;
17}Leaflet is a popular library for interactive maps that works independently from React. Here's an integration example:
1import React, { useEffect, useRef } from 'react';
2import L from 'leaflet';
3import 'leaflet/dist/leaflet.css';
4
5function MarsExplorationMap({ missionCoordinates, roverPosition, zoom = 5 }) {
6 // Ref to the map container
7 const mapContainerRef = useRef(null);
8 // Ref to the map instance (to avoid re-creation)
9 const mapInstanceRef = useRef(null);
10 // Ref to the rover marker (to update only its position)
11 const roverMarkerRef = useRef(null);
12
13 // Map initialization
14 useEffect(() => {
15 if (!mapInstanceRef.current && mapContainerRef.current) {
16 // Create map instance only once
17 const mapInstance = L.map(mapContainerRef.current).setView(
18 [missionCoordinates.lat, missionCoordinates.lng],
19 zoom
20 );
21
22 // Add map layer
23 L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
24 attribution: '© OpenStreetMap contributors'
25 }).addTo(mapInstance);
26
27 // Save map instance in reference
28 mapInstanceRef.current = mapInstance;
29
30 // Add rover marker
31 roverMarkerRef.current = L.marker([roverPosition.lat, roverPosition.lng])
32 .addTo(mapInstance)
33 .bindPopup('Mars Rover');
34 }
35
36 // Cleanup function
37 return () => {
38 if (mapInstanceRef.current) {
39 // Remove map when component unmounts
40 mapInstanceRef.current.remove();
41 mapInstanceRef.current = null;
42 roverMarkerRef.current = null;
43 }
44 };
45 }, []); // Empty dependency array - initialize only once
46
47 // Update rover position
48 useEffect(() => {
49 if (roverMarkerRef.current && roverPosition) {
50 // Update the position of the existing marker
51 roverMarkerRef.current.setLatLng([roverPosition.lat, roverPosition.lng]);
52 }
53 }, [roverPosition]); // Update only when rover position changes
54
55 // Update zoom level
56 useEffect(() => {
57 if (mapInstanceRef.current) {
58 mapInstanceRef.current.setZoom(zoom);
59 }
60 }, [zoom]); // Update only when zoom changes
61
62 return <div ref={mapContainerRef} style={{ height: '500px', width: '100%' }} />;
63}In the example above:
Chart.js is a popular library for creating interactive charts:
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 // Make sure we have access to the canvas element
10 if (!chartRef.current) return;
11
12 // Get canvas context
13 const ctx = chartRef.current.getContext('2d');
14
15 // Destroy previous chart instance if it exists
16 if (chartInstanceRef.current) {
17 chartInstanceRef.current.destroy();
18 }
19
20 // Create new chart instance
21 chartInstanceRef.current = new Chart(ctx, {
22 type: 'bar',
23 data: {
24 labels: resources.map(resource => resource.name),
25 datasets: [{
26 label: title || 'Mission Resources',
27 data: resources.map(resource => resource.level),
28 backgroundColor: resources.map(resource => {
29 // Color bars based on resource level
30 if (resource.level < 20) return 'rgba(255, 0, 0, 0.7)'; // Critical
31 if (resource.level < 50) return 'rgba(255, 165, 0, 0.7)'; // Warning
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 // Cleanup
50 return () => {
51 if (chartInstanceRef.current) {
52 chartInstanceRef.current.destroy();
53 chartInstanceRef.current = null;
54 }
55 };
56 }, [resources, title]); // Recreate chart when data or title changes
57
58 return <canvas ref={chartRef} />;
59}Web Socket integration enables real-time communication:
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 // Socket initialization
9 const socket = new WebSocket(`wss://mission-control.example.com/ws/${missionId}`);
10
11 // Event configuration
12 socket.onopen = () => {
13 setStatus('connected');
14 console.log(`Connected to mission ${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('Connection error with mission control center');
25 console.error('WebSocket error:', err);
26 };
27
28 socket.onclose = () => {
29 setStatus('disconnected');
30 console.log('Connection closed');
31 };
32
33 // Cleanup function
34 return () => {
35 // Close connection on unmount
36 if (socket.readyState === WebSocket.OPEN) {
37 socket.close();
38 }
39 };
40 }, [missionId, onMessage]); // Reconnect when missionId changes
41
42 return (
43 <div className="mission-socket">
44 <div className={`connection-status ${status}`}>
45 Connection status: {status}
46 </div>
47 {error && <div className="connection-error">{error}</div>}
48 </div>
49 );
50}Avoid unnecessarily re-initializing external systems when only part of the configuration changes. Instead:
1// BAD - single effect re-initializes everything
2useEffect(() => {
3 const chart = new ChartLibrary(config);
4 return () => chart.destroy();
5}, [config]); // Every change in config causes re-initialization
6
7// GOOD - separate initialization and update
8// Ref to store instance
9const chartRef = useRef(null);
10
11// Initialize only once
12useEffect(() => {
13 chartRef.current = new ChartLibrary(containerRef.current);
14 return () => chartRef.current.destroy();
15}, []);
16
17// Update configuration without re-initialization
18useEffect(() => {
19 if (chartRef.current) {
20 chartRef.current.updateConfig(config);
21 }
22}, [config]);When working with external data sources that use subscriptions:
1function TelemetryMonitor({ satelliteId, dataType }) {
2 const [data, setData] = useState(null);
3
4 useEffect(() => {
5 // Create subscription
6 const subscription = telemetryAPI.subscribe(
7 satelliteId,
8 dataType,
9 (newData) => setData(newData)
10 );
11
12 // Cleanup
13 return () => subscription.unsubscribe();
14 }, [satelliteId, dataType]); // Re-subscribe when ID or data type changes
15
16 return <div>{/* Rendering data */}</div>;
17}For heavy external libraries, you can delay their initialization until they are actually needed:
1function HeavyChart({ data, shouldRender }) {
2 const [chartInstance, setChartInstance] = useState(null);
3 const containerRef = useRef(null);
4
5 // Lazy initialization
6 useEffect(() => {
7 if (!shouldRender) {
8 // Don't initialize if we shouldn't render yet
9 return;
10 }
11
12 // Dynamic import of heavy library
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]); // Run only when shouldRender changes to true
22
23 // Update data
24 useEffect(() => {
25 if (chartInstance && data) {
26 chartInstance.updateData(data);
27 }
28 }, [chartInstance, data]);
29
30 return <div ref={containerRef} />;
31}To improve code reuse and encapsulate synchronization logic, it's worth creating custom hooks.
1function useLeafletMap(containerRef, initialConfig) {
2 const mapRef = useRef(null);
3 const [isReady, setIsReady] = useState(false);
4
5 // Initialization
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 // Methods for manipulating the 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// Using the custom hook
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 // Add marker after map loads
53 useEffect(() => {
54 if (isReady) {
55 const marker = addMarker([24.7136, 46.6753], { title: 'Base Alpha' });
56 marker.bindPopup('Base Alpha - Main colony on Mars').openPopup();
57 }
58 }, [isReady, addMarker]);
59
60 return (
61 <div>
62 <h2>Mars Colony Map</h2>
63 <div ref={containerRef} style={{ height: '500px' }} />
64 {!isReady && <div>Loading map...</div>}
65 </div>
66 );
67}1function useLocalStorage(key, initialValue) {
2 // State initializer function - runs only once
3 const [storedValue, setStoredValue] = useState(() => {
4 try {
5 // Check if localStorage is available
6 if (typeof window === 'undefined') {
7 return initialValue;
8 }
9
10 // Try to get value from localStorage
11 const item = window.localStorage.getItem(key);
12 return item ? JSON.parse(item) : initialValue;
13 } catch (error) {
14 console.error('Error reading from localStorage:', error);
15 return initialValue;
16 }
17 });
18
19 // Function to update value in localStorage
20 const setValue = useCallback((value) => {
21 try {
22 // Support function as value (like in setState)
23 const valueToStore = value instanceof Function ? value(storedValue) : value;
24
25 // Update React state
26 setStoredValue(valueToStore);
27
28 // Update localStorage
29 if (typeof window !== 'undefined') {
30 window.localStorage.setItem(key, JSON.stringify(valueToStore));
31 }
32 } catch (error) {
33 console.error('Error writing to localStorage:', error);
34 }
35 }, [key, storedValue]);
36
37 // Synchronize with localStorage when key changes
38 useEffect(() => {
39 // Get current value for new key
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('Error synchronizing with localStorage:', error);
47 }
48 }, [key, initialValue]);
49
50 return [storedValue, setValue];
51}
52
53// Using the custom hook
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>Astronaut Settings</h2>
71 <button onClick={toggleDarkMode}>
72 Switch to {settings.darkMode ? 'light' : 'dark'} mode
73 </button>
74 <div>
75 Oxygen level alert: {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 Enable notifications
97 </label>
98 </div>
99 );
100}Testing components that synchronize with external systems requires a special approach:
Example test for a WebSocket component:
1import { render, act, screen, waitFor } from '@testing-library/react';
2import MissionControlSocket from './MissionControlSocket';
3
4// Mock for WebSocket
5class MockWebSocket {
6 constructor(url) {
7 this.url = url;
8 this.readyState = WebSocket.CONNECTING;
9
10 // Auto "connect" after creation
11 setTimeout(() => {
12 this.readyState = WebSocket.OPEN;
13 this.onopen && this.onopen();
14 }, 0);
15 }
16
17 // Methods for simulating external events
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// Replace global WebSocket class
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 // Wait for connection
51 await waitFor(() => {
52 expect(screen.getByText(/connection status: connected/i)).toBeInTheDocument();
53 });
54
55 // Simulate receiving a message
56 const testData = { type: 'telemetry', oxygen: 95, fuel: 76 };
57 act(() => {
58 // Access WebSocket instance through monkeypatching
59 const socket = global.WebSocket.instances[0];
60 socket.simulateMessage(testData);
61 });
62
63 // Check if callback was called with appropriate data
64 expect(mockOnMessage).toHaveBeenCalledWith(testData);
65
66 // Simulate error
67 act(() => {
68 const socket = global.WebSocket.instances[0];
69 socket.simulateError();
70 });
71
72 // Check if error status is visible
73 expect(screen.getByText(/connection status: error/i)).toBeInTheDocument();
74 expect(screen.getByText(/connection error/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 // Get WebSocket instance
83 const socket = global.WebSocket.instances[0];
84 const closeSpy = jest.spyOn(socket, 'close');
85
86 // Unmount the component
87 unmount();
88
89 // Check if socket was closed
90 expect(closeSpy).toHaveBeenCalled();
91 });
92});Synchronization with external systems is one of the most important functions of the useEffect hook. It enables the use of existing libraries and APIs within the React ecosystem while maintaining the reactive update model.
Key principles:
Remember that the main goal of synchronization is to maintain consistency between the reactive world of React and external systems that know nothing about the component lifecycle. With proper synchronization, your application will work smoothly and efficiently, connecting different technologies into one cohesive whole.