Cours JavaScript et React · Module 10: Écosystème et avenir de React

WebSockets et Server-Sent Events dans React

19 min de lecture
Dans cette leçon3

Le radar de la station doit afficher la position des vaisseaux en direct. Une API REST classique fonctionne comme une lettre : le client demande, le serveur répond et la conversation s'arrête. Pour voir les changements, tu devrais demander chaque seconde, en gaspillant la bande passante et la batterie. La communication en temps réel inverse ce modèle : le serveur envoie lui-même les données quand quelque chose se produit. Dans le navigateur, tu disposes pour cela de deux technologies : WebSocket et Server-Sent Events (SSE).

WebSockets - communication bidirectionnelle en temps réel

Un WebSocket commence comme une requête HTTP ordinaire, puis passe à son propre protocole (ws:// ou sa version chiffrée wss://). À partir de ce moment, les deux côtés peuvent envoyer des messages texte ou binaires à tout instant. C'est le meilleur choix pour un chat, les notifications en direct, les données boursières et les jeux, c'est-à-dire partout où le client émet lui aussi souvent.

Implémentation de base d'un WebSocket

Le hook useWebSocket regroupe tout le cycle de vie de la connexion : connexion, réception, envoi, déconnexion et nouvelles tentatives :

1// hooks/useWebSocket.js
2import { useState, useEffect, useRef, useCallback } from 'react';
3
4const useWebSocket = (url, options = {}) => {
5  const [socket, setSocket] = useState(null);
6  const [lastMessage, setLastMessage] = useState(null);
7  const [readyState, setReadyState] = useState(0);
8  const [connectionError, setConnectionError] = useState(null);
9
10  // Socket, listeners et compteur de tentatives dans des refs - les modifier ne déclenche pas de rendu
11  const socketRef = useRef(null);
12  const messageListeners = useRef(new Set());
13  const retryTimeoutRef = useRef(null);
14  const reconnectAttemptsRef = useRef(0);
15
16  // Dernières options du dernier rendu : les callbacks passés en ligne
17  // ne changent pas connect, donc l'effet ne se reconnecte pas
18  const optionsRef = useRef(options);
19  useEffect(() => {
20    optionsRef.current = options;
21  });
22
23  const { heartbeatInterval = 30000 } = options;
24
25  // Fonction de création de la connexion - ne dépend que de url
26  const connect = useCallback(() => {
27    const { protocols, reconnectAttempts = 5, reconnectInterval = 3000 } = optionsRef.current;
28
29    try {
30      const ws = new WebSocket(url, protocols);
31      socketRef.current = ws;
32
33      ws.onopen = (event) => {
34        console.log('WebSocket connecté :', url);
35        setReadyState(1); // OPEN
36        setConnectionError(null);
37        reconnectAttemptsRef.current = 0;
38        optionsRef.current.onOpen?.(event);
39      };
40
41      ws.onmessage = (event) => {
42        let parsedData;
43        try {
44          parsedData = JSON.parse(event.data);
45        } catch {
46          parsedData = event.data;
47        }
48
49        setLastMessage({
50          data: parsedData,
51          timestamp: Date.now(),
52          raw: event.data
53        });
54
55        // Appeler tous les listeners enregistrés
56        messageListeners.current.forEach(listener => {
57          listener(parsedData, event);
58        });
59
60        optionsRef.current.onMessage?.(parsedData, event);
61      };
62
63      ws.onclose = (event) => {
64        // Ignorer les événements d'une ancienne socket (après reconnect ou démontage)
65        if (socketRef.current !== ws) return;
66
67        console.log('WebSocket déconnecté :', event.code, event.reason);
68        setReadyState(3); // CLOSED
69        setSocket(null);
70        optionsRef.current.onClose?.(event);
71
72        // Reconnexion automatique
73        if (!event.wasClean && reconnectAttemptsRef.current < reconnectAttempts) {
74          reconnectAttemptsRef.current++;
75          console.log(`Reconnexion (${reconnectAttemptsRef.current}/${reconnectAttempts})...`);
76
77          retryTimeoutRef.current = setTimeout(() => {
78            connect();
79          }, reconnectInterval * reconnectAttemptsRef.current);
80        }
81      };
82
83      ws.onerror = (error) => {
84        if (socketRef.current !== ws) return;
85        console.error('Erreur WebSocket :', error);
86        setConnectionError(error);
87        optionsRef.current.onError?.(error);
88      };
89
90      setSocket(ws);
91      setReadyState(0); // CONNECTING
92
93    } catch (error) {
94      console.error('Impossible de créer le WebSocket :', error);
95      setConnectionError(error);
96    }
97  }, [url]);
98
99  // Envoi des messages
100  const sendMessage = useCallback((message) => {
101    const ws = socketRef.current;
102    if (ws && ws.readyState === WebSocket.OPEN) {
103      const dataToSend = typeof message === 'string' ? message : JSON.stringify(message);
104      ws.send(dataToSend);
105      return true;
106    } else {
107      console.warn('Le WebSocket n’est pas connecté');
108      return false;
109    }
110  }, []);
111
112  // Ajout d'un listener de messages
113  const addMessageListener = useCallback((listener) => {
114    messageListeners.current.add(listener);
115    return () => messageListeners.current.delete(listener);
116  }, []);
117
118  // Reconnexion manuelle
119  const reconnect = useCallback(() => {
120    clearTimeout(retryTimeoutRef.current);
121    socketRef.current?.close();
122    reconnectAttemptsRef.current = 0;
123    connect();
124  }, [connect]);
125
126  // Déconnexion
127  const disconnect = useCallback(() => {
128    clearTimeout(retryTimeoutRef.current);
129    socketRef.current?.close(1000, 'Fermé par l’utilisateur');
130  }, []);
131
132  // Connexion automatique au montage
133  useEffect(() => {
134    connect();
135
136    return () => {
137      clearTimeout(retryTimeoutRef.current);
138      const ws = socketRef.current;
139      socketRef.current = null; // le onclose de l'ancienne socket ne fera plus rien
140      ws?.close();
141    };
142  }, [connect]);
143
144  // Heartbeat - ping/pong
145  useEffect(() => {
146    if (socket && readyState === 1 && heartbeatInterval > 0) {
147      const interval = setInterval(() => {
148        if (socket.readyState === WebSocket.OPEN) {
149          sendMessage({ type: 'ping', timestamp: Date.now() });
150        }
151      }, heartbeatInterval);
152
153      return () => clearInterval(interval);
154    }
155  }, [socket, readyState, heartbeatInterval, sendMessage]);
156
157  return {
158    socket,
159    lastMessage,
160    readyState,
161    connectionError,
162    sendMessage,
163    addMessageListener,
164    reconnect,
165    disconnect,
166    isConnecting: readyState === 0,
167    isOpen: readyState === 1,
168    isClosing: readyState === 2,
169    isClosed: readyState === 3
170  };
171};
172
173export default useWebSocket;

readyState reflète les états de la socket (0 connexion, 1 ouverte, 2 fermeture, 3 fermée), donc tu obtiens un indicateur de statut gratuitement. sendMessage n'envoie que lorsque la connexion est ouverte, et après une fermeture anormale le hook réessaie avec un délai croissant. Un heartbeat toutes les 30 secondes envoie ping, pour que les intermédiaires ne coupent pas une liaison inactive.

Le hook évite deux pièges. Les callbacks comme onOpen, passé en ligne dans le chat ci-dessous, et le tableau protocols sont nouveaux à chaque rendu, donc si connect en dépendait, l'effet rouvrirait la connexion après chaque rendu. C'est pourquoi le hook lit les options dans optionsRef, mis à jour après chaque rendu, et connect ne dépend que de url. La socket elle-même se trouve dans socketRef, car sinon le cleanup verrait la socket du rendu dans lequel l'effet a démarré, c'est-à-dire null. Le hook ignore les événements d'une ancienne socket, fermée par reconnect ou au démontage, donc il ne relancera pas la connexion après la sortie du chat.

Chat en temps réel

Une fois la connexion ouverte, ChatRoom envoie un message join, et répartit chaque message reçu avec une instruction switch selon le champ type :

1// components/ChatRoom.jsx
2import React, { useState, useEffect, useRef } from 'react';
3import useWebSocket from '../hooks/useWebSocket';
4
5const ChatRoom = ({ roomId, userId, userName }) => {
6  const [messages, setMessages] = useState([]);
7  const [newMessage, setNewMessage] = useState('');
8  const [typingUsers, setTypingUsers] = useState(new Set());
9  const [onlineUsers, setOnlineUsers] = useState(new Map());
10  const messagesEndRef = useRef(null);
11  const typingTimeoutRef = useRef(null);
12
13  const {
14    sendMessage,
15    lastMessage,
16    isOpen,
17    connectionError,
18    addMessageListener
19  } = useWebSocket(`ws://localhost:8080/chat/${roomId}`, {
20    onOpen: () => {
21      // Rejoindre le salon
22      sendMessage({
23        type: 'join',
24        roomId,
25        userId,
26        userName
27      });
28    }
29  });
30
31  // Gestion des messages reçus
32  useEffect(() => {
33    if (!lastMessage) return;
34
35    const { data } = lastMessage;
36
37    switch (data.type) {
38      case 'message':
39        setMessages(prev => [...prev, {
40          id: data.id,
41          text: data.text,
42          userId: data.userId,
43          userName: data.userName,
44          timestamp: data.timestamp
45        }]);
46        break;
47
48      case 'typing':
49        setTypingUsers(prev => {
50          const newSet = new Set(prev);
51          if (data.isTyping && data.userId !== userId) {
52            newSet.add(data.userName);
53          } else {
54            newSet.delete(data.userName);
55          }
56          return newSet;
57        });
58        break;
59
60      case 'userJoined':
61        setOnlineUsers(prev => new Map(prev).set(data.userId, data.userName));
62        setMessages(prev => [...prev, {
63          id: `system-${Date.now()}`,
64          text: `${data.userName} a rejoint le chat`,
65          type: 'system',
66          timestamp: Date.now()
67        }]);
68        break;
69
70      case 'userLeft':
71        setOnlineUsers(prev => {
72          const newMap = new Map(prev);
73          newMap.delete(data.userId);
74          return newMap;
75        });
76        setMessages(prev => [...prev, {
77          id: `system-${Date.now()}`,
78          text: `${data.userName} a quitté le chat`,
79          type: 'system',
80          timestamp: Date.now()
81        }]);
82        break;
83
84      case 'messageDeleted':
85        setMessages(prev => prev.filter(msg => msg.id !== data.messageId));
86        break;
87
88      case 'messageEdited':
89        setMessages(prev => prev.map(msg =>
90          msg.id === data.messageId
91            ? { ...msg, text: data.newText, edited: true }
92            : msg
93        ));
94        break;
95    }
96  }, [lastMessage, userId]);
97
98  // Défilement automatique vers le dernier message
99  useEffect(() => {
100    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
101  }, [messages]);
102
103  // Envoi des messages
104  const handleSendMessage = (e) => {
105    e.preventDefault();
106
107    if (newMessage.trim() && isOpen) {
108      sendMessage({
109        type: 'message',
110        text: newMessage.trim(),
111        roomId,
112        userId,
113        userName
114      });
115
116      setNewMessage('');
117      handleStopTyping();
118    }
119  };
120
121  // Gestion de la saisie
122  const handleTyping = (e) => {
123    setNewMessage(e.target.value);
124
125    if (!typingTimeoutRef.current) {
126      sendMessage({
127        type: 'typing',
128        isTyping: true,
129        userId,
130        userName,
131        roomId
132      });
133    }
134
135    clearTimeout(typingTimeoutRef.current);
136    typingTimeoutRef.current = setTimeout(() => {
137      handleStopTyping();
138    }, 2000);
139  };
140
141  const handleStopTyping = () => {
142    if (typingTimeoutRef.current) {
143      clearTimeout(typingTimeoutRef.current);
144      typingTimeoutRef.current = null;
145
146      sendMessage({
147        type: 'typing',
148        isTyping: false,
149        userId,
150        userName,
151        roomId
152      });
153    }
154  };
155
156  // Suppression d'un message
157  const handleDeleteMessage = (messageId) => {
158    sendMessage({
159      type: 'deleteMessage',
160      messageId,
161      userId
162    });
163  };
164
165  if (connectionError) {
166    return (
167      <div className="chat-error">
168        <h3>Erreur de connexion au chat</h3>
169        <p>{connectionError.message}</p>
170        <button onClick={() => window.location.reload()}>
171          Réessayer
172        </button>
173      </div>
174    );
175  }
176
177  return (
178    <div className="chat-room">
179      <div className="chat-header">
180        <h2>Salon : {roomId}</h2>
181        <div className="online-users">
182          Online ({onlineUsers.size}): {Array.from(onlineUsers.values()).join(', ')}
183        </div>
184        <div className="connection-status">
185          Statut : {isOpen ? 'Connecté' : 'Déconnecté'}
186        </div>
187      </div>
188
189      <div className="chat-messages">
190        {messages.map(message => (
191          <div
192            key={message.id}
193            className={`message ${message.type === 'system' ? 'system' : ''} ${message.userId === userId ? 'own' : ''}`}
194          >
195            {message.type !== 'system' && (
196              <div className="message-header">
197                <span className="username">{message.userName}</span>
198                <span className="timestamp">
199                  {new Date(message.timestamp).toLocaleTimeString()}
200                </span>
201                {message.userId === userId && (
202                  <button
203                    onClick={() => handleDeleteMessage(message.id)}
204                    className="delete-btn"
205                  >
206                    Supprimer
207                  </button>
208                )}
209              </div>
210            )}
211            <div className="message-text">
212              {message.text}
213              {message.edited && <span className="edited"> (modifié)</span>}
214            </div>
215          </div>
216        ))}
217
218        {typingUsers.size > 0 && (
219          <div className="typing-indicator">
220            {Array.from(typingUsers).join(', ')} écrit...
221          </div>
222        )}
223
224        <div ref={messagesEndRef} />
225      </div>
226
227      <form onSubmit={handleSendMessage} className="chat-input">
228        <input
229          type="text"
230          value={newMessage}
231          onChange={handleTyping}
232          onBlur={handleStopTyping}
233            disabled={!isOpen}
234          maxLength={500}
235        />
236        <button type="submit" disabled={!isOpen || !newMessage.trim()}>
237          Envoyer
238        </button>
239      </form>
240    </div>
241  );
242};
243
244export default ChatRoom;

Les types typing, userJoined et messageEdited forment un protocole convenu avec le serveur, pas une partie du standard WebSocket. L'indicateur de saisie envoie isTyping: false après deux secondes de silence, donc il n'inonde pas le serveur de messages.

Middleware et autorisation

À mesure que le chat grandit, les règles pour les messages se multiplient. WebSocketMiddleware les fait passer par une chaîne de fonctions, comme un douanier au sas :

1// utils/websocketMiddleware.js
2class WebSocketMiddleware {
3  constructor() {
4    this.middlewares = [];
5  }
6
7  use(middleware) {
8    this.middlewares.push(middleware);
9  }
10
11  async processIncoming(message, socket) {
12    let processedMessage = message;
13
14    for (const middleware of this.middlewares) {
15      try {
16        processedMessage = await middleware.incoming(processedMessage, socket);
17        if (!processedMessage) break; // Un middleware peut arrêter le traitement
18      } catch (error) {
19        console.error('Middleware error (incoming):', error);
20        break;
21      }
22    }
23
24    return processedMessage;
25  }
26
27  async processOutgoing(message, socket) {
28    let processedMessage = message;
29
30    for (const middleware of this.middlewares) {
31      try {
32        processedMessage = await middleware.outgoing(processedMessage, socket);
33        if (!processedMessage) break;
34      } catch (error) {
35        console.error('Middleware error (outgoing):', error);
36        break;
37      }
38    }
39
40    return processedMessage;
41  }
42}
43
44// Middleware d'authentification
45const authMiddleware = {
46  incoming: async (message, socket) => {
47    if (message.type === 'auth') {
48      const token = message.token;
49      const isValid = await validateToken(token);
50
51      if (isValid) {
52        socket.isAuthenticated = true;
53        socket.userId = getUserIdFromToken(token);
54        return { type: 'authSuccess', userId: socket.userId };
55      } else {
56        socket.close(4001, 'Unauthorized');
57        return null;
58      }
59    }
60
61    if (!socket.isAuthenticated && message.type !== 'auth') {
62      socket.send(JSON.stringify({
63        type: 'error',
64        message: 'Authentication required'
65      }));
66      return null;
67    }
68
69    return message;
70  },
71
72  outgoing: async (message, socket) => {
73    if (!socket.isAuthenticated && message.type !== 'authRequired') {
74      return null;
75    }
76    return message;
77  }
78};
79
80// Middleware de limitation du débit
81const rateLimitMiddleware = {
82  incoming: async (message, socket) => {
83    if (!socket.messageCount) {
84      socket.messageCount = 0;
85      socket.lastReset = Date.now();
86    }
87
88    const now = Date.now();
89    const timePassed = now - socket.lastReset;
90
91    // Réinitialisation toutes les minutes
92    if (timePassed > 60000) {
93      socket.messageCount = 0;
94      socket.lastReset = now;
95    }
96
97    socket.messageCount++;
98
99    // Au maximum 100 messages par minute
100    if (socket.messageCount > 100) {
101      socket.send(JSON.stringify({
102        type: 'error',
103        message: 'Rate limit exceeded'
104      }));
105      return null;
106    }
107
108    return message;
109  },
110
111  outgoing: async (message, socket) => message
112};
113
114// Hook avec middleware
115const useAdvancedWebSocket = (url, options = {}) => {
116  // Enregistrer les middlewares une seule fois, au premier rendu (StrictMode lancerait un effet deux fois)
117  const middleware = useRef(null);
118  if (middleware.current === null) {
119    middleware.current = new WebSocketMiddleware();
120    middleware.current.use(authMiddleware);
121    middleware.current.use(rateLimitMiddleware);
122  }
123
124  const { sendMessage: baseSendMessage, socket, ...rest } = useWebSocket(url, {
125    ...options,
126    onMessage: async (data, event) => {
127      const processed = await middleware.current.processIncoming(data, event.target);
128      if (processed) {
129        options.onMessage?.(processed, event);
130      }
131    }
132  });
133
134  const sendMessage = useCallback(async (message) => {
135    if (socket) {
136      const processed = await middleware.current.processOutgoing(message, socket);
137      if (processed) {
138        return baseSendMessage(processed);
139      }
140    }
141    return false;
142  }, [socket, baseSendMessage]);
143
144  return {
145    ...rest,
146    sendMessage,
147    socket
148  };
149};

Un middleware qui renvoie null arrête le message. useAdvancedWebSocket enregistre les middlewares une seule fois, au premier rendu, et prend la socket dans useWebSocket. La vraie autorisation et la limitation du débit doivent cependant tourner sur le serveur, car l'utilisateur peut modifier le code dans le navigateur, et côté client un tel middleware ne fait que mettre de l'ordre dans le trafic.

Server-Sent Events (SSE) - communication unidirectionnelle

Le SSE est une réponse HTTP ordinaire de type text/event-stream, que le serveur ne ferme pas, mais à laquelle il ajoute de nouveaux événements. Les données circulent uniquement du serveur vers le client, et seulement sous forme de texte. En échange, l'EventSource du navigateur reprend tout seul une connexion coupée et envoie l'en-tête Last-Event-ID, pour ne perdre aucun événement. Le hook useServerSentEvents l'enveloppe et gère les événements nommés :

1// hooks/useServerSentEvents.js
2import { useState, useEffect, useRef, useCallback } from 'react';
3
4const useServerSentEvents = (url, options = {}) => {
5  const [data, setData] = useState(null);
6  const [readyState, setReadyState] = useState(0);
7  const [error, setError] = useState(null);
8  const eventSourceRef = useRef(null);
9  const listenersRef = useRef(new Map());
10
11  // Dernières options sans reconnexion, comme dans useWebSocket
12  const optionsRef = useRef(options);
13  useEffect(() => {
14    optionsRef.current = options;
15  });
16
17  const { withCredentials = false } = options;
18
19  const connect = useCallback(() => {
20    try {
21      const eventSource = new EventSource(url, { withCredentials });
22      eventSourceRef.current = eventSource;
23
24      eventSource.onopen = (event) => {
25        console.log('SSE connecté :', url);
26        setReadyState(1); // OPEN
27        setError(null);
28        optionsRef.current.onOpen?.(event);
29      };
30
31      eventSource.onmessage = (event) => {
32        let parsedData;
33        try {
34          parsedData = JSON.parse(event.data);
35        } catch {
36          parsedData = event.data;
37        }
38
39        setData({
40          data: parsedData,
41          timestamp: Date.now(),
42          id: event.lastEventId,
43          type: event.type
44        });
45
46        optionsRef.current.onMessage?.(parsedData, event);
47      };
48
49      eventSource.onerror = (event) => {
50        console.error('Erreur SSE :', event);
51        setReadyState(eventSource.readyState); // 0 = le navigateur se reconnecte, 2 = fermé
52        setError(event);
53        optionsRef.current.onError?.(event);
54      };
55
56      // Ajouter des event listeners personnalisés
57      listenersRef.current.forEach((listener, eventType) => {
58        eventSource.addEventListener(eventType, listener);
59      });
60
61    } catch (err) {
62      console.error('Impossible de créer l’EventSource :', err);
63      setError(err);
64    }
65  }, [url, withCredentials]);
66
67  const addEventListener = useCallback((eventType, listener) => {
68    listenersRef.current.set(eventType, listener);
69
70    if (eventSourceRef.current) {
71      eventSourceRef.current.addEventListener(eventType, listener);
72    }
73
74    return () => {
75      listenersRef.current.delete(eventType);
76      if (eventSourceRef.current) {
77        eventSourceRef.current.removeEventListener(eventType, listener);
78      }
79    };
80  }, []);
81
82  const close = useCallback(() => {
83    if (eventSourceRef.current) {
84      eventSourceRef.current.close();
85      eventSourceRef.current = null;
86      setReadyState(2);
87    }
88  }, []);
89
90  useEffect(() => {
91    connect();
92
93    return () => {
94      close();
95    };
96  }, [connect, close]);
97
98  return {
99    data,
100    readyState,
101    error,
102    addEventListener,
103    close,
104    reconnect: connect,
105    isConnecting: readyState === 0,
106    isOpen: readyState === 1,
107    isClosed: readyState === 2
108  };
109};
110
111export default useServerSentEvents;

onmessage capte les événements sans nom, et addEventListener('metrics', ...) ceux qui ont le champ event: metrics. Dans onerror, le hook enregistre eventSource.readyState : 0 signifie que le navigateur se reconnecte tout seul, et 2 que la connexion est fermée pour de bon. Les nouvelles tentatives dépendent du navigateur et du champ retry: envoyé par le serveur, donc le hook ne compte pas lui-même les tentatives. Il lit les options dans optionsRef, comme useWebSocket, donc un onMessage passé en ligne ne rouvre pas la connexion à chaque rendu.

LiveDashboard écoute quatre événements nommés : les métriques, les notifications, le statut et les alertes :

1// components/LiveDashboard.jsx
2import React, { useState, useEffect } from 'react';
3import useServerSentEvents from '../hooks/useServerSentEvents';
4
5const LiveDashboard = () => {
6  const [metrics, setMetrics] = useState({
7    activeUsers: 0,
8    serverLoad: 0,
9    memoryUsage: 0,
10    requestsPerSecond: 0
11  });
12
13  const [notifications, setNotifications] = useState([]);
14  const [systemStatus, setSystemStatus] = useState('healthy');
15  const [lastUpdate, setLastUpdate] = useState(null);
16
17  const { data, isOpen, error, addEventListener } = useServerSentEvents(
18    '/api/dashboard/stream',
19    {
20      onMessage: (data) => {
21        setLastUpdate(new Date());
22      }
23    }
24  );
25
26  // Handler pour les différents types d'événements
27  useEffect(() => {
28    const removeMetricsListener = addEventListener('metrics', (event) => {
29      const metricsData = JSON.parse(event.data);
30      setMetrics(metricsData);
31    });
32
33    const removeNotificationListener = addEventListener('notification', (event) => {
34      const notification = JSON.parse(event.data);
35      setNotifications(prev => [notification, ...prev.slice(0, 9)]); // Au maximum 10 notifications
36    });
37
38    const removeStatusListener = addEventListener('status', (event) => {
39      const status = JSON.parse(event.data);
40      setSystemStatus(status.level);
41    });
42
43    const removeAlertListener = addEventListener('alert', (event) => {
44      const alert = JSON.parse(event.data);
45
46      // Afficher l'alerte comme notification du navigateur
47      if (Notification.permission === 'granted') {
48        new Notification(`System Alert: ${alert.type}`, {
49          body: alert.message,
50          icon: '/icons/alert.png'
51        });
52      }
53
54      setNotifications(prev => [{
55        ...alert,
56        type: 'alert',
57        timestamp: Date.now()
58      }, ...prev.slice(0, 9)]);
59    });
60
61    return () => {
62      removeMetricsListener();
63      removeNotificationListener();
64      removeStatusListener();
65      removeAlertListener();
66    };
67  }, [addEventListener]);
68
69  // Demander l'autorisation après un clic : Firefox et Safari ignorent une demande sans geste de l'utilisateur
70  const [notificationPermission, setNotificationPermission] = useState(Notification.permission);
71
72  const requestNotifications = async () => {
73    setNotificationPermission(await Notification.requestPermission());
74  };
75
76  const getStatusColor = (status) => {
77    switch (status) {
78      case 'healthy': return '#4CAF50';
79      case 'warning': return '#FF9800';
80      case 'critical': return '#F44336';
81      default: return '#9E9E9E';
82    }
83  };
84
85  const getMetricColor = (value, thresholds) => {
86    if (value >= thresholds.critical) return '#F44336';
87    if (value >= thresholds.warning) return '#FF9800';
88    return '#4CAF50';
89  };
90
91  if (error) {
92    return (
93      <div className="dashboard-error">
94        <h2>Erreur de connexion au tableau de bord</h2>
95        <p>Impossible d'établir la connexion avec le serveur de monitoring.</p>
96        <button onClick={() => window.location.reload()}>
97          Rafraîchir la page
98        </button>
99      </div>
100    );
101  }
102
103  return (
104    <div className="live-dashboard">
105      <header className="dashboard-header">
106        <h1>Live Dashboard</h1>
107        <div className="connection-info">
108          <span
109            className={`status-indicator ${isOpen ? 'connected' : 'disconnected'}`}
110            style={{ backgroundColor: isOpen ? '#4CAF50' : '#F44336' }}
111          />
112          {isOpen ? 'Connecté' : 'Déconnecté'}
113          {lastUpdate && (
114            <span className="last-update">
115              Dernière mise à jour : {lastUpdate.toLocaleTimeString()}
116            </span>
117          )}
118        </div>
119        {notificationPermission === 'default' && (
120          <button onClick={requestNotifications}>Activer les notifications</button>
121        )}
122      </header>
123
124      <div className="metrics-grid">
125        <div className="metric-card">
126          <h3>Utilisateurs actifs</h3>
127          <div
128            className="metric-value"
129            style={{ color: getMetricColor(metrics.activeUsers, { warning: 1000, critical: 1500 }) }}
130          >
131            {metrics.activeUsers.toLocaleString()}
132          </div>
133        </div>
134
135        <div className="metric-card">
136          <h3>Charge du serveur</h3>
137          <div
138            className="metric-value"
139            style={{ color: getMetricColor(metrics.serverLoad, { warning: 70, critical: 90 }) }}
140          >
141            {metrics.serverLoad}%
142          </div>
143          <div className="progress-bar">
144            <div
145              className="progress-fill"
146              style={{
147                width: `${metrics.serverLoad}%`,
148                backgroundColor: getMetricColor(metrics.serverLoad, { warning: 70, critical: 90 })
149              }}
150            />
151          </div>
152        </div>
153
154        <div className="metric-card">
155          <h3>Utilisation de la mémoire</h3>
156          <div
157            className="metric-value"
158            style={{ color: getMetricColor(metrics.memoryUsage, { warning: 80, critical: 95 }) }}
159          >
160            {metrics.memoryUsage}%
161          </div>
162          <div className="progress-bar">
163            <div
164              className="progress-fill"
165              style={{
166                width: `${metrics.memoryUsage}%`,
167                backgroundColor: getMetricColor(metrics.memoryUsage, { warning: 80, critical: 95 })
168              }}
169            />
170          </div>
171        </div>
172
173        <div className="metric-card">
174          <h3>Requêtes/s</h3>
175          <div
176            className="metric-value"
177            style={{ color: getMetricColor(metrics.requestsPerSecond, { warning: 1000, critical: 2000 }) }}
178          >
179            {metrics.requestsPerSecond}
180          </div>
181        </div>
182      </div>
183
184      <div className="system-status">
185        <h3>État du système</h3>
186        <div
187          className="status-badge"
188          style={{
189            backgroundColor: getStatusColor(systemStatus),
190            color: 'white',
191            padding: '8px 16px',
192            borderRadius: '20px',
193            display: 'inline-block'
194          }}
195        >
196          {systemStatus.toUpperCase()}
197        </div>
198      </div>
199
200      <div className="notifications-panel">
201        <h3>Notifications ({notifications.length})</h3>
202        <div className="notifications-list">
203          {notifications.map((notification, index) => (
204            <div
205              key={index}
206              className={`notification ${notification.type || 'info'}`}
207            >
208              <div className="notification-content">
209                <strong>{notification.title || notification.type}</strong>
210                <p>{notification.message}</p>
211              </div>
212              <div className="notification-time">
213                {new Date(notification.timestamp).toLocaleTimeString()}
214              </div>
215            </div>
216          ))}
217        </div>
218      </div>
219    </div>
220  );
221};
222
223export default LiveDashboard;

Chaque listener renvoie une fonction de nettoyage, donc le cleanup de l'effet les détache tous. Une alerte part aussi vers une notification système, si l'utilisateur les a acceptées auparavant. Un bouton demande l'autorisation, car Firefox et Safari ignorent une demande d'autorisation qui ne vient pas d'un geste de l'utilisateur.

Choisir entre WebSockets et SSE

Quand utiliser les WebSockets

Un éditeur collaboratif est le cas d'école du WebSocket : chaque utilisateur émet des modifications de texte et des mouvements de curseur, et reçoit en même temps ceux des autres :

1// Exemple : application de collaboration en temps réel
2const CollaborativeEditor = () => {
3  const { sendMessage, lastMessage, isOpen } = useWebSocket('/ws/document/123');
4  const [documentContent, setDocumentContent] = useState('');
5  const [cursors, setCursors] = useState(new Map());
6
7  const handleTextChange = (newContent, position) => {
8    setDocumentContent(newContent);
9
10    // Envoyer la modification aux autres utilisateurs
11    sendMessage({
12      type: 'content_change',
13      content: newContent,
14      position,
15      userId: currentUser.id
16    });
17  };
18
19  const handleCursorMove = (position) => {
20    sendMessage({
21      type: 'cursor_move',
22      position,
23      userId: currentUser.id
24    });
25  };
26
27  useEffect(() => {
28    if (lastMessage) {
29      switch (lastMessage.data.type) {
30        case 'content_change':
31          setDocumentContent(lastMessage.data.content);
32          break;
33        case 'cursor_move':
34          setCursors(prev => new Map(prev).set(
35            lastMessage.data.userId,
36            lastMessage.data.position
37          ));
38          break;
39      }
40    }
41  }, [lastMessage]);
42
43  return (
44    <div className="collaborative-editor">
45      {/* Éditeur avec collaboration en temps réel */}
46    </div>
47  );
48};

Le trafic circule dans les deux sens plusieurs fois par seconde, donc une seule connexion persistante l'emporte sur une série de requêtes HTTP séparées.

Quand utiliser le SSE

Un fil d'actualités est le cas inverse : le lecteur ne fait que recevoir les nouveaux articles, donc le SSE suffit :

1// Exemple : fil d'actualités en direct
2const NewsFeed = () => {
3  const [articles, setArticles] = useState([]);
4  const [newArticleCount, setNewArticleCount] = useState(0);
5
6  const { data, addEventListener } = useServerSentEvents('/api/news/stream');
7
8  useEffect(() => {
9    const removeArticleListener = addEventListener('new_article', (event) => {
10      const article = JSON.parse(event.data);
11      setArticles(prev => [article, ...prev]);
12      setNewArticleCount(prev => prev + 1);
13    });
14
15    const removeUpdateListener = addEventListener('article_update', (event) => {
16      const updatedArticle = JSON.parse(event.data);
17      setArticles(prev => prev.map(article =>
18        article.id === updatedArticle.id ? updatedArticle : article
19      ));
20    });
21
22    return () => {
23      removeArticleListener();
24      removeUpdateListener();
25    };
26  }, [addEventListener]);
27
28  return (
29    <div className="news-feed">
30      {newArticleCount > 0 && (
31        <div className="new-articles-banner">
32          {newArticleCount} nouveaux articles
33          <button onClick={() => setNewArticleCount(0)}>
34            Marquer comme lu
35          </button>
36        </div>
37      )}
38
39      {articles.map(article => (
40        <ArticleCard key={article.id} article={article} />
41      ))}
42    </div>
43  );
44};

Il n'y a ici aucun sendMessage. Si le client envoie parfois quelque chose, par exemple marque un article comme lu, il le fait avec une requête HTTP ordinaire.

Comparaison et bonnes pratiques

Pour finir, un utilitaire qui choisit la technologie en fonction des besoins :

1// Choix de la technologie selon les besoins - une simple fonction, pas un hook
2const chooseTransport = ({
3  bidirectional = false,
4  authentication = false,
5  highFrequency = false,
6  binaryData = false
7} = {}) =>
8  bidirectional || authentication || highFrequency || binaryData ? 'websocket' : 'sse';
9
10// Chaque canal est un composant distinct qui appelle toujours le même hook
11function WebSocketChannel({ url }) {
12  const { sendMessage, lastMessage } = useWebSocket(url.replace('http', 'ws'));
13  // Logique pour WebSocket
14  return <div>Canal WebSocket</div>;
15}
16
17function SseChannel({ url }) {
18  const { data, addEventListener } = useServerSentEvents(url);
19  // Logique pour SSE
20  return <div>Canal SSE</div>;
21}
22
23// Exemple d'utilisation
24const SmartComponent = () => {
25  const transport = chooseTransport({
26    bidirectional: true, // Nécessite WebSocket
27    authentication: true
28  });
29
30  return transport === 'websocket'
31    ? <WebSocketChannel url="/api/data" />
32    : <SseChannel url="/api/data" />;
33};

authentication désigne le WebSocket, car EventSource ne permet pas d'ajouter ses propres en-têtes, comme Authorization. Le choix lui-même est une simple fonction, et les hooks sont appelés par des composants distincts, donc chacun appelle toujours le même hook dans le même ordre, comme l'exigent les règles des hooks. Appeler useWebSocket ou useServerSentEvents de façon conditionnelle dans un seul composant enfreint ces règles.

Mon conseil : quand les données ne viennent que du serveur, commence par le SSE, car c'est du HTTP ordinaire avec reconnexion automatique. Passe au WebSocket quand le client émet lui aussi souvent. Dans l'exercice, tu construiras ton propre hook avec connexion, déconnexion, envoi et indicateur de statut.

Retiens : un WebSocket est un canal radio bidirectionnel, et le SSE un émetteur qui ne fait qu'émettre - choisis celui qui convient à la conversation.

Code de cette leçon : App.jsx
1import React, { useState, useEffect, useRef, useCallback } from 'react';
2
3// Simulation d’un serveur WebSocket : flux de télémétrie du vaisseau et réponses aux commandes du client
4// Un vrai code utiliserait new WebSocket(url) avec les gestionnaires onopen, onmessage et onclose
5class MockWebSocketServer {
6  constructor() {
7    this.handlers = null;
8    this.openTimer = null;
9    this.interval = null;
10  }
11
12  connect(handlers) {
13    this.handlers = handlers;
14    this.openTimer = setTimeout(() => {
15      handlers.onOpen();
16      this.interval = setInterval(() => {
17        handlers.onMessage({
18          type: 'TELEMETRY',
19          timestamp: Date.now(),
20          data: {
21            speed: (27600 + Math.random() * 400).toFixed(0),
22            altitude: (408 + Math.random() * 2).toFixed(1),
23            fuel: (85 - Math.random() * 0.5).toFixed(1),
24            temperature: (20 + Math.random() * 3).toFixed(1),
25            oxygen: (98.5 + Math.random() * 1.5).toFixed(1),
26          },
27        });
28      }, 1500);
29    }, 400);
30  }
31
32  // Le serveur répond à une commande du client - communication bidirectionnelle
33  send(message) {
34    setTimeout(() => {
35      this.handlers?.onMessage({ type: 'ACK', command: message.command, timestamp: Date.now() });
36    }, 300);
37  }
38
39  // Liaison coupée : fermeture sans arrêt propre (wasClean: false)
40  drop() {
41    const handlers = this.handlers;
42    this.stop();
43    handlers?.onClose({ wasClean: false });
44  }
45
46  close() {
47    const handlers = this.handlers;
48    this.stop();
49    handlers?.onClose({ wasClean: true });
50  }
51
52  stop() {
53    clearTimeout(this.openTimer);
54    clearInterval(this.interval);
55    this.handlers = null;
56  }
57}
58
59// Simulation de Server-Sent Events : le serveur envoie lui-même les notifications de mission, le client ne fait qu’écouter
60class MockSSEServer {
61  subscribe(onEvent) {
62    const events = [
63      { type: 'mission_update', data: 'Mission Nebula : étape 3 terminée' },
64      { type: 'alert', data: 'Anomalie gravitationnelle détectée dans le secteur 7' },
65      { type: 'crew_message', data: 'Dr. Stellar : analyse des échantillons terminée' },
66      { type: 'system', data: 'Mise à jour du logiciel de navigation disponible' },
67      { type: 'alert', data: 'Approche d’un champ d’astéroïdes - changement de cap' },
68      { type: 'mission_update', data: 'Nouvelle mission assignée : exploration d’Io' },
69    ];
70
71    let index = 0;
72    const interval = setInterval(() => {
73      const event = events[index % events.length];
74      index++;
75      onEvent({ ...event, id: index, timestamp: new Date().toLocaleTimeString('fr') });
76    }, 2500);
77
78    return () => clearInterval(interval);
79  }
80}
81
82const MAX_RETRIES = 3;
83const RETRY_DELAY = 2000;
84
85// Hook WebSocket : après une coupure de la liaison, il tente de se reconnecter
86// toutes les 2 secondes, 3 fois au maximum ; une fermeture par l’utilisateur ne relance pas la connexion
87function useWebSocket() {
88  const [status, setStatus] = useState('DISCONNECTED');
89  const [messages, setMessages] = useState([]);
90  const [attempt, setAttempt] = useState(0);
91  const serverRef = useRef(null);
92  const retryRef = useRef({ count: 0, timer: null });
93
94  const connect = useCallback(function openConnection() {
95    const server = new MockWebSocketServer();
96    serverRef.current = server;
97    setStatus('CONNECTING');
98    server.connect({
99      onOpen: () => {
100        retryRef.current.count = 0;
101        setAttempt(0);
102        setStatus('CONNECTED');
103      },
104      onMessage: (message) => setMessages((prev) => [message, ...prev].slice(0, 20)),
105      onClose: ({ wasClean }) => {
106        serverRef.current = null;
107        if (wasClean || retryRef.current.count >= MAX_RETRIES) {
108          setStatus('DISCONNECTED');
109          return;
110        }
111        // Fermeture anormale : nouvelle tentative après RETRY_DELAY
112        retryRef.current.count += 1;
113        setAttempt(retryRef.current.count);
114        setStatus('RECONNECTING');
115        retryRef.current.timer = setTimeout(openConnection, RETRY_DELAY);
116      },
117    });
118  }, []);
119
120  const disconnect = useCallback(() => {
121    clearTimeout(retryRef.current.timer);
122    retryRef.current.count = 0;
123    setAttempt(0);
124    if (serverRef.current) serverRef.current.close();
125    else setStatus('DISCONNECTED');
126  }, []);
127
128  const simulateDrop = useCallback(() => {
129    serverRef.current?.drop();
130  }, []);
131
132  const sendCommand = useCallback((command) => {
133    const message = { type: 'COMMAND', command, timestamp: Date.now() };
134    setMessages((prev) => [message, ...prev].slice(0, 20));
135    serverRef.current?.send(message);
136  }, []);
137
138  // Démontage : arrêter le serveur et la tentative de reconnexion prévue
139  useEffect(() => {
140    const retry = retryRef.current;
141    return () => {
142      clearTimeout(retry.timer);
143      serverRef.current?.stop();
144    };
145  }, []);
146
147  return { status, attempt, messages, connect, disconnect, simulateDrop, sendCommand };
148}
149
150const STATUS_LABELS = {
151  CONNECTING: 'Connexion...',
152  CONNECTED: 'Connecté',
153  RECONNECTING: 'Reconnexion',
154  DISCONNECTED: 'Déconnecté',
155};
156const STATUS_COLORS = { CONNECTING: '#ffa500', CONNECTED: '#00ff88', RECONNECTING: '#ffa500', DISCONNECTED: '#ff6b6b' };
157
158function TelemetryPanel() {
159  const { status, attempt, messages, connect, disconnect, simulateDrop, sendCommand } = useWebSocket();
160  const latest = messages.find((message) => message.type === 'TELEMETRY')?.data;
161  const lastAck = messages.find((message) => message.type === 'ACK');
162  const connected = status === 'CONNECTED';
163  const idle = status === 'DISCONNECTED';
164
165  const metrics = latest ? [
166    { label: 'Vitesse', value: `${latest.speed} km/h` },
167    { label: 'Altitude', value: `${latest.altitude} km` },
168    { label: 'Carburant', value: `${latest.fuel}%` },
169    { label: 'Température', value: `${latest.temperature} °C` },
170    { label: 'Oxygène', value: `${latest.oxygen}%` },
171  ] : [];
172
173  return (
174    <div style={styles.panel}>
175      <div style={styles.panelHeader}>
176        <h3 style={styles.heading}>WebSocket - télémétrie</h3>
177        <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
178          <span style={{ ...styles.dot, background: STATUS_COLORS[status] }} />
179          <span style={{ color: '#8892b0', fontSize: '12px' }}>
180            {STATUS_LABELS[status]}{status === 'RECONNECTING' ? ` (${attempt}/${MAX_RETRIES})` : ''}
181          </span>
182        </div>
183      </div>
184      <div style={styles.buttons}>
185        <button onClick={connect} disabled={!idle} style={{ ...styles.btn, opacity: idle ? 1 : 0.4 }}>Connecter</button>
186        <button onClick={disconnect} disabled={idle} style={{ ...styles.btnDanger, opacity: idle ? 0.4 : 1 }}>Déconnecter</button>
187        <button onClick={simulateDrop} disabled={!connected} style={{ ...styles.btnDanger, opacity: connected ? 1 : 0.4 }}>Couper la liaison</button>
188        <button onClick={() => sendCommand('PING')} disabled={!connected} style={{ ...styles.btn, opacity: connected ? 1 : 0.4 }}>Envoyer un ping</button>
189      </div>
190      {latest && (
191        <div style={styles.metrics}>
192          {metrics.map((metric) => (
193            <div key={metric.label} style={styles.metricCard}>
194              <div style={{ color: '#8892b0', fontSize: '11px' }}>{metric.label}</div>
195              <div style={{ color: '#00d4ff', fontSize: '17px', fontWeight: 'bold' }}>{metric.value}</div>
196            </div>
197          ))}
198        </div>
199      )}
200      {lastAck && <p style={{ color: '#00ff88', fontSize: '12px', margin: '12px 0 0' }}>Le serveur a confirmé la commande {lastAck.command} à {new Date(lastAck.timestamp).toLocaleTimeString('fr')}</p>}
201      {!latest && idle && <p style={styles.empty}>Clique sur « Connecter » pour lancer le flux de données.</p>}
202    </div>
203  );
204}
205
206const EVENT_LABELS = { mission_update: 'mission', alert: 'alerte', crew_message: 'équipage', system: 'système' };
207const EVENT_COLORS = { mission_update: '#00ff88', alert: '#ff6b6b', crew_message: '#ffa500', system: '#00d4ff' };
208
209function SSENotificationsPanel() {
210  const [events, setEvents] = useState([]);
211  const [listening, setListening] = useState(false);
212  const unsubscribeRef = useRef(null);
213
214  const startListening = () => {
215    unsubscribeRef.current = new MockSSEServer().subscribe((event) => {
216      setEvents((prev) => [event, ...prev].slice(0, 15));
217    });
218    setListening(true);
219  };
220
221  const stopListening = () => {
222    unsubscribeRef.current?.();
223    unsubscribeRef.current = null;
224    setListening(false);
225  };
226
227  // Fermer l’abonnement au démontage
228  useEffect(() => () => unsubscribeRef.current?.(), []);
229
230  return (
231    <div style={styles.panel}>
232      <div style={styles.panelHeader}>
233        <h3 style={styles.heading}>SSE - notifications</h3>
234        <button onClick={listening ? stopListening : startListening} style={listening ? styles.btnDanger : styles.btn}>
235          {listening ? 'Arrêter' : 'S’abonner'}
236        </button>
237      </div>
238      <div style={{ maxHeight: '300px', overflowY: 'auto' }}>
239        {events.length === 0 && <p style={styles.empty}>Aucune notification - clique sur « S’abonner ».</p>}
240        {events.map((event) => (
241          <div key={event.id} style={{ ...styles.event, borderLeftColor: EVENT_COLORS[event.type] }}>
242            <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
243              <span style={{ color: EVENT_COLORS[event.type], fontSize: '11px', textTransform: 'uppercase', fontWeight: 'bold' }}>{EVENT_LABELS[event.type]}</span>
244              <span style={{ color: '#8892b0', fontSize: '11px' }}>{event.timestamp}</span>
245            </div>
246            <div style={{ color: '#e0e1dd', fontSize: '13px' }}>{event.data}</div>
247          </div>
248        ))}
249      </div>
250    </div>
251  );
252}
253
254function App() {
255  return (
256    <div style={styles.container}>
257      <h1 style={styles.title}>Centre de communication en temps réel</h1>
258      <p style={styles.subtitle}>WebSockets et Server-Sent Events dans React</p>
259      <div style={styles.grid}>
260        <TelemetryPanel />
261        <SSENotificationsPanel />
262      </div>
263      <p style={styles.note}>Un WebSocket transporte les données dans les deux sens : le serveur envoie la télémétrie, et le client des commandes comme ping. « Couper la liaison » montre la reconnexion. Les SSE ne circulent que du serveur vers le client, donc le panneau de notifications n’envoie rien.</p>
264    </div>
265  );
266}
267
268const styles = {
269  container: { minHeight: '100vh', background: 'linear-gradient(135deg, #0d1b2a 0%, #1a1a3e 100%)', padding: '24px 16px', color: '#e0e1dd', fontFamily: 'system-ui, sans-serif', boxSizing: 'border-box' },
270  title: { textAlign: 'center', color: '#00d4ff', margin: '0 0 4px', fontSize: '24px' },
271  subtitle: { textAlign: 'center', color: '#8892b0', marginBottom: '20px' },
272  grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '16px' },
273  panel: { background: 'rgba(0,0,0,0.3)', borderRadius: '12px', padding: '16px', border: '1px solid rgba(0,212,255,0.2)' },
274  panelHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', flexWrap: 'wrap', marginBottom: '12px' },
275  heading: { color: '#00d4ff', margin: 0, fontSize: '17px' },
276  dot: { width: '10px', height: '10px', borderRadius: '50%', display: 'inline-block' },
277  buttons: { display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '12px' },
278  metrics: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(90px, 1fr))', gap: '8px' },
279  metricCard: { padding: '10px', background: 'rgba(0,212,255,0.05)', borderRadius: '8px', textAlign: 'center', border: '1px solid rgba(0,212,255,0.15)' },
280  event: { padding: '8px 12px', borderLeft: '3px solid', background: 'rgba(0,0,0,0.2)', marginBottom: '6px', borderRadius: '0 6px 6px 0' },
281  empty: { color: '#8892b0', textAlign: 'center', fontSize: '13px' },
282  btn: { padding: '8px 12px', background: 'rgba(0,212,255,0.15)', border: '1px solid #00d4ff', borderRadius: '6px', color: '#00d4ff', cursor: 'pointer', fontSize: '13px' },
283  btnDanger: { padding: '8px 12px', background: 'rgba(255,107,107,0.15)', border: '1px solid #ff6b6b', borderRadius: '6px', color: '#ff6b6b', cursor: 'pointer', fontSize: '13px' },
284  note: { maxWidth: '760px', margin: '16px auto 0', color: '#8892b0', fontSize: '13px', lineHeight: 1.5 },
285};
286
287export default App;

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quand WebSocket est-il un meilleur choix que l'API REST dans une application React ?

  2. 2. En quoi les Server-Sent Events (SSE) diffèrent-ils de WebSocket ?

Tâches pratiques dans le jeu

  • Éditeur de code

    Termine le hook useMissionSocket qui gère le canal de communication avec la station. La socket est créée par createSocket(url) - dans l'aperçu, c'est un simulateur de station avec la même interface que WebSocket. ___BLANK1___ : dans onmessage, transforme le texte de event.data en objet message avec JSON.parse. ___BLANK2___ : la fonction de nettoyage de l'effet ferme la socket avec close() quand le composant disparaît (aussi quand StrictMode lance l'effet deux fois). ___BLANK3___ : send envoie l'objet { text } transformé en texte avec JSON.stringify. Après quelques secondes, la télémétrie apparaît dans l'aperçu, et chacun de tes messages revient en écho.

Articles utiles