Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React

WebSockets i Server-Sent Events w React

17 min czytania
W tej lekcji3

Radar stacji ma pokazywać pozycje statków na bieżąco. Zwykłe REST API działa jak list: klient pyta, serwer odpowiada i rozmowa się kończy. Żeby widzieć zmiany, musiałbyś pytać co sekundę, marnując łącze i baterię. Komunikacja w czasie rzeczywistym odwraca ten model: serwer sam wysyła dane, gdy coś się wydarzy. W przeglądarce masz do tego dwie technologie: WebSocket i Server-Sent Events (SSE).

WebSockets - dwukierunkowa komunikacja w czasie rzeczywistym

WebSocket zaczyna jak zwykłe żądanie HTTP, a potem przełącza się na własny protokół (ws:// albo szyfrowany wss://). Od tej chwili obie strony mogą wysyłać wiadomości tekstowe lub binarne w dowolnym momencie. To najlepszy wybór dla czatu, powiadomień na żywo, danych giełdowych i gier, czyli wszędzie, gdzie klient też często nadaje.

Podstawowa implementacja WebSocket

Hook useWebSocket zamyka cały cykl życia połączenia: łączenie, odbiór, wysyłkę, rozłączanie i ponawianie:

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  // Gniazdo, listenery i licznik prób w refach - ich zmiana nie wywołuje renderu
11  const socketRef = useRef(null);
12  const messageListeners = useRef(new Set());
13  const retryTimeoutRef = useRef(null);
14  const reconnectAttemptsRef = useRef(0);
15
16  // Najnowsze opcje z ostatniego renderu: callbacki podane inline
17  // nie zmieniają connect, więc efekt nie łączy się od nowa
18  const optionsRef = useRef(options);
19  useEffect(() => {
20    optionsRef.current = options;
21  });
22
23  const { heartbeatInterval = 30000 } = options;
24
25  // Funkcja do tworzenia połączenia - zależy tylko od 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 połączony:', 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        // Wywołaj wszystkie zarejestrowane listenery
56        messageListeners.current.forEach(listener => {
57          listener(parsedData, event);
58        });
59
60        optionsRef.current.onMessage?.(parsedData, event);
61      };
62
63      ws.onclose = (event) => {
64        // Zdarzenia starego gniazda (po reconnect albo odmontowaniu) pomijamy
65        if (socketRef.current !== ws) return;
66
67        console.log('WebSocket rozłączony:', event.code, event.reason);
68        setReadyState(3); // CLOSED
69        setSocket(null);
70        optionsRef.current.onClose?.(event);
71
72        // Automatyczne ponowne łączenie
73        if (!event.wasClean && reconnectAttemptsRef.current < reconnectAttempts) {
74          reconnectAttemptsRef.current++;
75          console.log(`Ponowne łączenie (${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('WebSocket błąd:', 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('Nie udało się utworzyć WebSocket:', error);
95      setConnectionError(error);
96    }
97  }, [url]);
98
99  // Wysyłanie wiadomości
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('WebSocket nie jest połączony');
108      return false;
109    }
110  }, []);
111
112  // Dodawanie listenera wiadomości
113  const addMessageListener = useCallback((listener) => {
114    messageListeners.current.add(listener);
115    return () => messageListeners.current.delete(listener);
116  }, []);
117
118  // Ręczne połączenie
119  const reconnect = useCallback(() => {
120    clearTimeout(retryTimeoutRef.current);
121    socketRef.current?.close();
122    reconnectAttemptsRef.current = 0;
123    connect();
124  }, [connect]);
125
126  // Rozłączenie
127  const disconnect = useCallback(() => {
128    clearTimeout(retryTimeoutRef.current);
129    socketRef.current?.close(1000, 'Zamknięte przez użytkownika');
130  }, []);
131
132  // Automatyczne połączenie przy mount
133  useEffect(() => {
134    connect();
135
136    return () => {
137      clearTimeout(retryTimeoutRef.current);
138      const ws = socketRef.current;
139      socketRef.current = null; // onclose starego gniazda nic już nie zrobi
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 odwzorowuje stany gniazda (0 łączenie, 1 otwarte, 2 zamykanie, 3 zamknięte), więc wskaźnik statusu masz za darmo. sendMessage wysyła tylko przy otwartym połączeniu, a po nieczystym zamknięciu hook ponawia próbę z rosnącym odstępem. Heartbeat co 30 sekund wysyła ping, żeby pośrednicy nie zerwali bezczynnego łącza.

Hook omija dwie pułapki. Callbacki, jak onOpen podany inline w czacie poniżej, i tablica protocols są nowe przy każdym renderze, więc gdyby connect od nich zależał, efekt otwierałby połączenie od nowa po każdym renderze. Dlatego hook czyta opcje z optionsRef, odświeżanego po każdym renderze, a connect zależy tylko od url. Samo gniazdo leży w socketRef, bo cleanup widziałby inaczej socket z renderu, w którym efekt wystartował, czyli null. Zdarzenia starego gniazda, zamkniętego przez reconnect albo przy odmontowaniu, hook pomija, więc nie wznowi połączenia po wyjściu z czatu.

Czat w czasie rzeczywistym

ChatRoom po otwarciu połączenia wysyła wiadomość join, a każdą odebraną wiadomość rozdziela instrukcją switch według pola 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      // Dołącz do pokoju
22      sendMessage({
23        type: 'join',
24        roomId,
25        userId,
26        userName
27      });
28    }
29  });
30
31  // Obsługa otrzymanych wiadomości
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} dołączył do czatu`,
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} opuścił czat`,
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  // Auto-scroll do najnowszej wiadomości
99  useEffect(() => {
100    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
101  }, [messages]);
102
103  // Wysyłanie wiadomości
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  // Obsługa pisania
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  // Usuwanie wiadomości
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>Błąd połączenia z czatem</h3>
169        <p>{connectionError.message}</p>
170        <button onClick={() => window.location.reload()}>
171          Spróbuj ponownie
172        </button>
173      </div>
174    );
175  }
176
177  return (
178    <div className="chat-room">
179      <div className="chat-header">
180        <h2>Pokój: {roomId}</h2>
181        <div className="online-users">
182          Online ({onlineUsers.size}): {Array.from(onlineUsers.values()).join(', ')}
183        </div>
184        <div className="connection-status">
185          Status: {isOpen ? 'Połączony' : 'Rozłączony'}
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                    Usuń
207                  </button>
208                )}
209              </div>
210            )}
211            <div className="message-text">
212              {message.text}
213              {message.edited && <span className="edited"> (edytowane)</span>}
214            </div>
215          </div>
216        ))}
217
218        {typingUsers.size > 0 && (
219          <div className="typing-indicator">
220            {Array.from(typingUsers).join(', ')} pisze...
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          Wyślij
238        </button>
239      </form>
240    </div>
241  );
242};
243
244export default ChatRoom;

Typy typing, userJoined i messageEdited to protokół umówiony z serwerem, a nie część standardu WebSocket. Wskaźnik pisania wysyła isTyping: false po dwóch sekundach ciszy, więc nie zalewa serwera wiadomościami.

Middleware i autoryzacja

Wraz z rozwojem czatu przybywa reguł dla wiadomości. WebSocketMiddleware przepuszcza je przez łańcuch funkcji, jak celnik na śluzie:

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; // Middleware może zatrzymać przetwarzanie
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 do autentykacji
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 do rate limiting
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    // Reset co minutę
92    if (timePassed > 60000) {
93      socket.messageCount = 0;
94      socket.lastReset = now;
95    }
96
97    socket.messageCount++;
98
99    // Maksymalnie 100 wiadomości na minutę
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 z middleware
115const useAdvancedWebSocket = (url, options = {}) => {
116  // Middleware rejestrujemy raz, przy pierwszym renderze (efekt StrictMode uruchomiłby dwa razy)
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};

Middleware zwracający null zatrzymuje wiadomość. useAdvancedWebSocket rejestruje middleware raz, przy pierwszym renderze, a gniazdo bierze z useWebSocket. Prawdziwa autoryzacja i limitowanie muszą jednak działać na serwerze, bo kod w przeglądarce użytkownik może zmienić, a po stronie klienta taki middleware tylko porządkuje ruch.

Server-Sent Events (SSE) - jednostronna komunikacja

SSE to zwykła odpowiedź HTTP z typem text/event-stream, której serwer nie zamyka, tylko dopisuje kolejne zdarzenia. Dane płyną wyłącznie od serwera do klienta i tylko jako tekst. W zamian przeglądarkowy EventSource sam wznawia zerwane połączenie i wysyła nagłówek Last-Event-ID, żeby nie zgubić zdarzeń. Hook useServerSentEvents opakowuje go i obsługuje nazwane zdarzenia:

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  // Najnowsze opcje bez ponownego łączenia, jak w 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 połączone:', 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('SSE błąd:', event);
51        setReadyState(eventSource.readyState); // 0 = przeglądarka łączy się ponownie, 2 = zamknięte
52        setError(event);
53        optionsRef.current.onError?.(event);
54      };
55
56      // Dodaj custom event listeners
57      listenersRef.current.forEach((listener, eventType) => {
58        eventSource.addEventListener(eventType, listener);
59      });
60
61    } catch (err) {
62      console.error('Nie udało się utworzyć 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 łapie zdarzenia bez nazwy, a addEventListener('metrics', ...) te z polem event: metrics. W onerror hook zapisuje eventSource.readyState: 0 oznacza, że przeglądarka sama łączy się ponownie, a 2, że połączenie zamknięto na dobre. O ponowieniach decyduje przeglądarka i pole retry: od serwera, więc hook nie liczy prób sam. Opcje czyta z optionsRef, tak jak useWebSocket, więc onMessage podany inline nie otwiera połączenia od nowa przy każdym renderze.

LiveDashboard nasłuchuje czterech nazwanych zdarzeń: metryk, powiadomień, statusu i alertów:

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 dla różnych typów eventów
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)]); // Maksymalnie 10 notyfikacji
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      // Pokazuj alert jako browser notification
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  // O zgodę pytaj po kliknięciu: Firefox i Safari ignorują prośbę bez gestu użytkownika
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>Błąd połączenia z dashboard</h2>
95        <p>Nie można nawiązać połączenia z serwerem monitoringu.</p>
96        <button onClick={() => window.location.reload()}>
97          Odśwież stronę
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 ? 'Połączony' : 'Rozłączony'}
113          {lastUpdate && (
114            <span className="last-update">
115              Ostatnia aktualizacja: {lastUpdate.toLocaleTimeString()}
116            </span>
117          )}
118        </div>
119        {notificationPermission === 'default' && (
120          <button onClick={requestNotifications}>Włącz powiadomienia</button>
121        )}
122      </header>
123
124      <div className="metrics-grid">
125        <div className="metric-card">
126          <h3>Aktywni użytkownicy</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>Obciążenie serwera</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>Użycie pamięci</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>Żądania/sek</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>Status systemu</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>Powiadomienia ({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;

Każdy listener zwraca funkcję sprzątającą, więc cleanup efektu odpina je wszystkie. Alert trafia też do systemowego powiadomienia, jeśli użytkownik wcześniej się na nie zgodził. O zgodę prosi przycisk, bo Firefox i Safari ignorują prośbę o uprawnienie, która nie wynika z gestu użytkownika.

Wybór między WebSockets a SSE

Kiedy używać WebSockets

Edytor we współpracy to podręcznikowy przypadek WebSocket: każdy użytkownik nadaje zmiany tekstu i ruchy kursora, a jednocześnie odbiera cudze:

1// Przykład: Aplikacja do kolaboracji w czasie rzeczywistym
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    // Wyślij zmianę do innych użytkowników
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      {/* Edytor z real-time collaboration */}
46    </div>
47  );
48};

Ruch płynie w obie strony wiele razy na sekundę, więc jedno trwałe połączenie wygrywa z serią osobnych żądań HTTP.

Kiedy używać SSE

Kanał wiadomości to odwrotny przypadek: czytelnik tylko odbiera nowe artykuły, więc wystarczy SSE:

1// Przykład: Live feed aktualności
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} nowych artykułów
33          <button onClick={() => setNewArticleCount(0)}>
34            Oznacz jako przeczytane
35          </button>
36        </div>
37      )}
38
39      {articles.map(article => (
40        <ArticleCard key={article.id} article={article} />
41      ))}
42    </div>
43  );
44};

Nie ma tu żadnego sendMessage. Jeśli klient czasem coś wysyła, na przykład oznacza artykuł jako przeczytany, zrobi to zwykłym żądaniem HTTP.

Porównanie i najlepsze praktyki

Na koniec pomocnik, który wybiera technologię na podstawie wymagań:

1// Wybór technologii na podstawie wymagań - zwykła funkcja, nie 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// Każdy kanał to osobny komponent, który zawsze wywołuje ten sam hook
11function WebSocketChannel({ url }) {
12  const { sendMessage, lastMessage } = useWebSocket(url.replace('http', 'ws'));
13  // Logika dla WebSocket
14  return <div>Kanał WebSocket</div>;
15}
16
17function SseChannel({ url }) {
18  const { data, addEventListener } = useServerSentEvents(url);
19  // Logika dla SSE
20  return <div>Kanał SSE</div>;
21}
22
23// Przykład użycia
24const SmartComponent = () => {
25  const transport = chooseTransport({
26    bidirectional: true, // Wymaga WebSocket
27    authentication: true
28  });
29
30  return transport === 'websocket'
31    ? <WebSocketChannel url="/api/data" />
32    : <SseChannel url="/api/data" />;
33};

authentication wskazuje WebSocket, bo EventSource nie pozwala dodać własnych nagłówków, jak Authorization. Sam wybór to zwykła funkcja, a hooki wywołują osobne komponenty, więc każdy z nich woła zawsze ten sam hook w tej samej kolejności, czego wymagają zasady hooków. Warunkowe wywołanie useWebSocket albo useServerSentEvents w jednym komponencie te zasady łamie.

Moja rada: gdy dane płyną tylko od serwera, zacznij od SSE, bo to zwykłe HTTP z automatycznym wznawianiem. Po WebSocket sięgaj, gdy klient też często nadaje. W ćwiczeniu zbudujesz własny hook z połączeniem, rozłączaniem, wysyłką i wskaźnikiem statusu.

Pamiętaj: WebSocket to dwukierunkowy kanał radiowy, a SSE to nadajnik, który tylko nadaje - wybierz ten, który pasuje do rozmowy.

Kod do tej lekcji: App.jsx
1import React, { useState, useEffect, useRef, useCallback } from 'react';
2
3// Symulacja serwera WebSocket: strumień telemetrii statku i odpowiedzi na komendy klienta
4// Prawdziwy kod użyłby new WebSocket(url) z handlerami onopen, onmessage i 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  // Serwer odpowiada na komendę klienta - komunikacja w obie strony
33  send(message) {
34    setTimeout(() => {
35      this.handlers?.onMessage({ type: 'ACK', command: message.command, timestamp: Date.now() });
36    }, 300);
37  }
38
39  // Zerwane łącze: zamknięcie bez czystego zakończenia (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// Symulacja Server-Sent Events: serwer sam wysyła powiadomienia misji, klient tylko słucha
60class MockSSEServer {
61  subscribe(onEvent) {
62    const events = [
63      { type: 'mission_update', data: 'Misja Nebula: etap 3 ukończony' },
64      { type: 'alert', data: 'Wykryto anomalię grawitacyjną w sektorze 7' },
65      { type: 'crew_message', data: 'Dr. Stellar: analiza próbek zakończona' },
66      { type: 'system', data: 'Dostępna aktualizacja oprogramowania nawigacyjnego' },
67      { type: 'alert', data: 'Zbliżamy się do pola asteroid - zmiana kursu' },
68      { type: 'mission_update', data: 'Nowa misja przydzielona: eksploracja 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('pl') });
76    }, 2500);
77
78    return () => clearInterval(interval);
79  }
80}
81
82const MAX_RETRIES = 3;
83const RETRY_DELAY = 2000;
84
85// Hook do obsługi WebSocket: po zerwaniu łącza próbuje połączyć się ponownie
86// co 2 sekundy, najwyżej 3 razy; zamknięcie przez użytkownika nie wznawia połączenia
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        // Nieczyste zamknięcie: kolejna próba po 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  // Odmontowanie: zatrzymaj serwer i zaplanowaną próbę połączenia
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: 'Łączenie...',
152  CONNECTED: 'Połączono',
153  RECONNECTING: 'Ponowne łączenie',
154  DISCONNECTED: 'Rozłączono',
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: 'Prędkość', value: `${latest.speed} km/h` },
167    { label: 'Wysokość', value: `${latest.altitude} km` },
168    { label: 'Paliwo', value: `${latest.fuel}%` },
169    { label: 'Temperatura', value: `${latest.temperature} °C` },
170    { label: 'Tlen', value: `${latest.oxygen}%` },
171  ] : [];
172
173  return (
174    <div style={styles.panel}>
175      <div style={styles.panelHeader}>
176        <h3 style={styles.heading}>WebSocket - telemetria</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 }}>Połącz</button>
186        <button onClick={disconnect} disabled={idle} style={{ ...styles.btnDanger, opacity: idle ? 0.4 : 1 }}>Rozłącz</button>
187        <button onClick={simulateDrop} disabled={!connected} style={{ ...styles.btnDanger, opacity: connected ? 1 : 0.4 }}>Zerwij łącze</button>
188        <button onClick={() => sendCommand('PING')} disabled={!connected} style={{ ...styles.btn, opacity: connected ? 1 : 0.4 }}>Wyślij 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' }}>Serwer potwierdził komendę {lastAck.command} o {new Date(lastAck.timestamp).toLocaleTimeString('pl')}</p>}
201      {!latest && idle && <p style={styles.empty}>Kliknij "Połącz", aby uruchomić strumień danych.</p>}
202    </div>
203  );
204}
205
206const EVENT_LABELS = { mission_update: 'misja', alert: 'alarm', crew_message: 'załoga', system: 'system' };
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  // Przy odmontowaniu zamknij subskrypcję
228  useEffect(() => () => unsubscribeRef.current?.(), []);
229
230  return (
231    <div style={styles.panel}>
232      <div style={styles.panelHeader}>
233        <h3 style={styles.heading}>SSE - powiadomienia</h3>
234        <button onClick={listening ? stopListening : startListening} style={listening ? styles.btnDanger : styles.btn}>
235          {listening ? 'Zatrzymaj' : 'Subskrybuj'}
236        </button>
237      </div>
238      <div style={{ maxHeight: '300px', overflowY: 'auto' }}>
239        {events.length === 0 && <p style={styles.empty}>Brak powiadomień - kliknij "Subskrybuj".</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}>Centrum łączności w czasie rzeczywistym</h1>
258      <p style={styles.subtitle}>WebSockets i Server-Sent Events w React</p>
259      <div style={styles.grid}>
260        <TelemetryPanel />
261        <SSENotificationsPanel />
262      </div>
263      <p style={styles.note}>WebSocket przesyła dane w obie strony: serwer wysyła telemetrię, a klient komendy, jak ping. "Zerwij łącze" pokazuje ponowne łączenie. SSE płynie tylko od serwera do klienta, więc panel powiadomień niczego nie wysyła.</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;

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Kiedy WebSocket jest lepszym wyborem niż REST API w aplikacji React?

  2. 2. Czym Server-Sent Events (SSE) różni się od WebSocket?

Zadania praktyczne w grze

  • Edytor kodu

    Dokończ hook useMissionSocket, który obsługuje kanał łączności ze stacją. Gniazdo tworzy createSocket(url) - w podglądzie to symulator stacji o takim samym interfejsie jak WebSocket. ___BLANK1___: w onmessage zamień tekst z event.data na obiekt wiadomości przez JSON.parse. ___BLANK2___: funkcja cleanup efektu zamyka gniazdo metodą close(), gdy komponent znika (także przy podwójnym uruchomieniu efektu w StrictMode). ___BLANK3___: send wysyła obiekt { text } zamieniony na tekst przez JSON.stringify. Po kilku sekundach w podglądzie pojawi się telemetria, a każda Twoja wiadomość wróci jako echo.

Przydatne artykuły