We use cookies to enhance your experience on the site
CodeWorlds

Synchronization with External Systems

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.

Why is synchronization important?

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:

  • Map libraries (e.g., Google Maps, Leaflet)
  • Chart libraries (e.g., D3.js, Chart.js)
  • Media players (e.g., HTML5 Video, Web Audio API)
  • WebSocket SDKs and real-time communication
  • Form management systems (e.g., Formik, React Hook Form)
  • Animation libraries (e.g., GSAP, Anime.js)
  • Browser interfaces (geolocation, Web Bluetooth)

Basic synchronization pattern

The basic pattern for synchronization with an external system looks as follows:

  1. Initialize the external library/system after the first render
  2. Update the external system when relevant props or state change
  3. Clean up (stop, disconnect) when the component is unmounted
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}

Practical synchronization examples

1. Integration with a map library (Leaflet)

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:

  1. We use references (useRef) to store DOM elements and external library instances
  2. We initialize the map only once and save its instance
  3. We use separate effects to handle different aspects of synchronization
  4. We clean up resources when the component is unmounted

2. Integration with a chart library (Chart.js)

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}

3. Handling Web Sockets

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}

Strategies for efficient synchronization

1. Minimizing re-initializations

Avoid unnecessarily re-initializing external systems when only part of the configuration changes. Instead:

  1. Use separate effects for different aspects of synchronization
  2. Use references to store instances of external libraries
  3. Update existing instances instead of creating new ones
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]);

2. Managing subscriptions

When working with external data sources that use subscriptions:

  1. Always cancel subscriptions in the cleanup function
  2. Update subscriptions when parameters change
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}

3. Lazy initialization of resource-heavy libraries

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}

Creating custom hooks for synchronization

To improve code reuse and encapsulate synchronization logic, it's worth creating custom hooks.

Example: Custom hook for map integration

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}

Example: Custom hook for local storage

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}

Handling effects in tests

Testing components that synchronize with external systems requires a special approach:

  1. Mocking external libraries
  2. Testing cleanup functions
  3. Verifying that synchronization works correctly

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});

Summary

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:

  1. Initialize external systems after the first render
  2. Update them when relevant data changes
  3. Clean up resources when the component is unmounted
  4. Separate effects by their responsibility
  5. Encapsulate synchronization logic in dedicated hooks
  6. Minimize unnecessary re-initializations
  7. Test the correctness of synchronization and cleanup

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.

Go to CodeWorlds