JavaScript and React course ยท Module 10: React Ecosystem and Future
WebSockets and Server-Sent Events in React
In this lesson3
The station's radar has to show ship positions live. A regular REST API works like a letter: the client asks, the server answers and the conversation ends. To see changes, you would have to ask every second, wasting bandwidth and battery. Real-time communication flips this model: the server sends data by itself when something happens. In the browser you have two technologies for this: WebSocket and Server-Sent Events (SSE).
WebSockets - Two-Way Real-Time Communication
A WebSocket starts like a regular HTTP request, then switches to its own protocol (ws:// or the encrypted wss://). From that moment both sides can send text or binary messages at any time. It is the best choice for chat, live notifications, stock data and games, meaning anywhere the client also transmits often.
Basic WebSocket Implementation
The useWebSocket hook wraps the whole connection lifecycle: connecting, receiving, sending, disconnecting and retrying:
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 and the retry counter in refs - changing them does not trigger a render
11 const socketRef = useRef(null);
12 const messageListeners = useRef(new Set());
13 const retryTimeoutRef = useRef(null);
14 const reconnectAttemptsRef = useRef(0);
15
16 // Latest options from the last render: callbacks passed inline
17 // do not change connect, so the effect does not reconnect
18 const optionsRef = useRef(options);
19 useEffect(() => {
20 optionsRef.current = options;
21 });
22
23 const { heartbeatInterval = 30000 } = options;
24
25 // Function to create connection - depends only on 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 connected:', 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 // Call all registered listeners
56 messageListeners.current.forEach(listener => {
57 listener(parsedData, event);
58 });
59
60 optionsRef.current.onMessage?.(parsedData, event);
61 };
62
63 ws.onclose = (event) => {
64 // Ignore events of an old socket (after reconnect or unmount)
65 if (socketRef.current !== ws) return;
66
67 console.log('WebSocket disconnected:', event.code, event.reason);
68 setReadyState(3); // CLOSED
69 setSocket(null);
70 optionsRef.current.onClose?.(event);
71
72 // Automatic reconnection
73 if (!event.wasClean && reconnectAttemptsRef.current < reconnectAttempts) {
74 reconnectAttemptsRef.current++;
75 console.log(`Reconnecting (${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 error:', 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('Failed to create WebSocket:', error);
95 setConnectionError(error);
96 }
97 }, [url]);
98
99 // Sending 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('WebSocket is not connected');
108 return false;
109 }
110 }, []);
111
112 // Adding message listener
113 const addMessageListener = useCallback((listener) => {
114 messageListeners.current.add(listener);
115 return () => messageListeners.current.delete(listener);
116 }, []);
117
118 // Manual reconnection
119 const reconnect = useCallback(() => {
120 clearTimeout(retryTimeoutRef.current);
121 socketRef.current?.close();
122 reconnectAttemptsRef.current = 0;
123 connect();
124 }, [connect]);
125
126 // Disconnect
127 const disconnect = useCallback(() => {
128 clearTimeout(retryTimeoutRef.current);
129 socketRef.current?.close(1000, 'Closed by user');
130 }, []);
131
132 // Automatic connection on mount
133 useEffect(() => {
134 connect();
135
136 return () => {
137 clearTimeout(retryTimeoutRef.current);
138 const ws = socketRef.current;
139 socketRef.current = null; // the old socket's onclose will do nothing
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 mirrors the socket states (0 connecting, 1 open, 2 closing, 3 closed), so you get a status indicator for free. sendMessage sends only while the connection is open, and after an unclean close the hook retries with a growing delay. A heartbeat every 30 seconds sends ping, so intermediaries do not drop an idle link.
The hook avoids two traps. Callbacks such as onOpen passed inline in the chat below, and the protocols array, are new on every render, so if connect depended on them, the effect would reopen the connection after every render. That is why the hook reads the options from optionsRef, refreshed after every render, and connect depends only on url. The socket itself lives in socketRef, because otherwise the cleanup would see the socket from the render in which the effect started, which is null. The hook ignores events from an old socket, closed by reconnect or on unmount, so it will not resume the connection after you leave the chat.
Real-Time Chat
After the connection opens, ChatRoom sends a join message, and it dispatches every received message with a switch statement based on the type field:
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 // Join room
22 sendMessage({
23 type: 'join',
24 roomId,
25 userId,
26 userName
27 });
28 }
29 });
30
31 // Handle received messages
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} joined the 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} left the 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 // Auto-scroll to latest message
99 useEffect(() => {
100 messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
101 }, [messages]);
102
103 // Sending 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 // Typing handler
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 // Delete 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>Chat connection error</h3>
169 <p>{connectionError.message}</p>
170 <button onClick={() => window.location.reload()}>
171 Try again
172 </button>
173 </div>
174 );
175 }
176
177 return (
178 <div className="chat-room">
179 <div className="chat-header">
180 <h2>Room: {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 ? 'Connected' : 'Disconnected'}
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 Delete
207 </button>
208 )}
209 </div>
210 )}
211 <div className="message-text">
212 {message.text}
213 {message.edited && <span className="edited"> (edited)</span>}
214 </div>
215 </div>
216 ))}
217
218 {typingUsers.size > 0 && (
219 <div className="typing-indicator">
220 {Array.from(typingUsers).join(', ')} is typing...
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 Send
238 </button>
239 </form>
240 </div>
241 );
242};
243
244export default ChatRoom;The typing, userJoined and messageEdited types are a protocol agreed with the server, not part of the WebSocket standard. The typing indicator sends isTyping: false after two seconds of silence, so it does not flood the server with messages.
Middleware and Authorization
As the chat grows, so do the rules for messages. WebSocketMiddleware passes them through a chain of functions, like a customs officer at the airlock:
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 can stop processing
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// Authentication middleware
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// Rate limiting middleware
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 every minute
92 if (timePassed > 60000) {
93 socket.messageCount = 0;
94 socket.lastReset = now;
95 }
96
97 socket.messageCount++;
98
99 // Maximum 100 messages per 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 with middleware
115const useAdvancedWebSocket = (url, options = {}) => {
116 // Register the middleware once, on the first render (StrictMode would run an effect twice)
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};A middleware that returns null stops the message. useAdvancedWebSocket registers the middleware once, on the first render, and takes the socket from useWebSocket. Real authorization and rate limiting must still run on the server, though, because the user can change code in the browser, and on the client side such middleware only keeps traffic tidy.
Server-Sent Events (SSE) - One-Way Communication
SSE is a regular HTTP response with the text/event-stream type, which the server does not close, but keeps appending new events to. Data flows only from the server to the client, and only as text. In return, the browser's EventSource resumes a dropped connection by itself and sends the Last-Event-ID header, so no events are lost. The useServerSentEvents hook wraps it and handles named events:
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 // Latest options without reconnecting, as in 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 connected:', 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 error:', event);
51 setReadyState(eventSource.readyState); // 0 = the browser is reconnecting, 2 = closed
52 setError(event);
53 optionsRef.current.onError?.(event);
54 };
55
56 // Add custom event listeners
57 listenersRef.current.forEach((listener, eventType) => {
58 eventSource.addEventListener(eventType, listener);
59 });
60
61 } catch (err) {
62 console.error('Failed to create 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 catches unnamed events, and addEventListener('metrics', ...) the ones with the event: metrics field. In onerror the hook stores eventSource.readyState: 0 means the browser is reconnecting by itself, and 2 that the connection was closed for good. Retries are decided by the browser and the retry: field sent by the server, so the hook does not count attempts itself. It reads the options from optionsRef, just like useWebSocket, so an onMessage passed inline does not reopen the connection on every render.
LiveDashboard listens for four named events: metrics, notifications, status and alerts:
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 for different event types
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)]); // 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 // Show alert as 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 // Ask for permission after a click: Firefox and Safari ignore a request without a user gesture
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>Dashboard connection error</h2>
95 <p>Unable to establish connection to the monitoring server.</p>
96 <button onClick={() => window.location.reload()}>
97 Refresh 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 ? 'Connected' : 'Disconnected'}
113 {lastUpdate && (
114 <span className="last-update">
115 Last update: {lastUpdate.toLocaleTimeString()}
116 </span>
117 )}
118 </div>
119 {notificationPermission === 'default' && (
120 <button onClick={requestNotifications}>Enable notifications</button>
121 )}
122 </header>
123
124 <div className="metrics-grid">
125 <div className="metric-card">
126 <h3>Active Users</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>Server Load</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>Memory Usage</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>Requests/sec</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>System Status</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;Each listener returns a cleanup function, so the effect's cleanup detaches them all. An alert also goes to a system notification, if the user has agreed to them beforehand. A button asks for the permission, because Firefox and Safari ignore a permission request that does not come from a user gesture.
Choosing Between WebSockets and SSE
When to Use WebSockets
A collaborative editor is the textbook WebSocket case: every user transmits text changes and cursor movements, and at the same time receives everyone else's:
1// Example: Real-time collaboration application
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 // Send change to other users
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 {/* Editor with real-time collaboration */}
46 </div>
47 );
48};Traffic flows both ways many times per second, so one persistent connection beats a series of separate HTTP requests.
When to Use SSE
A news feed is the opposite case: the reader only receives new articles, so SSE is enough:
1// Example: Live news feed
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} new articles
33 <button onClick={() => setNewArticleCount(0)}>
34 Mark as read
35 </button>
36 </div>
37 )}
38
39 {articles.map(article => (
40 <ArticleCard key={article.id} article={article} />
41 ))}
42 </div>
43 );
44};There is no sendMessage here. If the client occasionally sends something, for example marks an article as read, it does so with a regular HTTP request.
Comparison and Best Practices
Finally, a helper that picks the technology based on the requirements:
1// Choosing the technology based on requirements - a plain function, not a 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// Each channel is a separate component that always calls the same hook
11function WebSocketChannel({ url }) {
12 const { sendMessage, lastMessage } = useWebSocket(url.replace('http', 'ws'));
13 // WebSocket logic
14 return <div>WebSocket channel</div>;
15}
16
17function SseChannel({ url }) {
18 const { data, addEventListener } = useServerSentEvents(url);
19 // SSE logic
20 return <div>SSE channel</div>;
21}
22
23// Usage example
24const SmartComponent = () => {
25 const transport = chooseTransport({
26 bidirectional: true, // Requires WebSocket
27 authentication: true
28 });
29
30 return transport === 'websocket'
31 ? <WebSocketChannel url="/api/data" />
32 : <SseChannel url="/api/data" />;
33};authentication points to WebSocket, because EventSource does not let you add custom headers, such as Authorization. The choice itself is a plain function, and the hooks are called by separate components, so each of them always calls the same hook in the same order, as the rules of hooks require. Calling useWebSocket or useServerSentEvents conditionally in a single component breaks those rules.
My advice: when data flows only from the server, start with SSE, because it is plain HTTP with automatic reconnection. Reach for WebSocket when the client also transmits often. In the exercise you will build your own hook with connecting, disconnecting, sending and a status indicator.
Remember: a WebSocket is a two-way radio channel, and SSE is a transmitter that only broadcasts - choose the one that fits the conversation.
Code for this lesson: App.jsx
1import React, { useState, useEffect, useRef, useCallback } from 'react';
2
3// WebSocket server simulation: the ship telemetry stream and replies to client commands
4// Real code would use new WebSocket(url) with onopen, onmessage and onclose handlers
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 // The server replies to a client command - two-way communication
33 send(message) {
34 setTimeout(() => {
35 this.handlers?.onMessage({ type: 'ACK', command: message.command, timestamp: Date.now() });
36 }, 300);
37 }
38
39 // Dropped link: a close without a clean shutdown (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// Server-Sent Events simulation: the server pushes mission notifications, the client only listens
60class MockSSEServer {
61 subscribe(onEvent) {
62 const events = [
63 { type: 'mission_update', data: 'Nebula mission: stage 3 completed' },
64 { type: 'alert', data: 'Gravitational anomaly detected in sector 7' },
65 { type: 'crew_message', data: 'Dr. Stellar: sample analysis completed' },
66 { type: 'system', data: 'Navigation software update available' },
67 { type: 'alert', data: 'Approaching an asteroid field - changing course' },
68 { type: 'mission_update', data: 'New mission assigned: exploration of 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('en') });
76 }, 2500);
77
78 return () => clearInterval(interval);
79 }
80}
81
82const MAX_RETRIES = 3;
83const RETRY_DELAY = 2000;
84
85// WebSocket hook: after the link drops, it tries to reconnect
86// every 2 seconds, at most 3 times; a close by the user does not resume the connection
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 // Unclean close: next attempt after 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 // Unmount: stop the server and the scheduled reconnection attempt
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: 'Connecting...',
152 CONNECTED: 'Connected',
153 RECONNECTING: 'Reconnecting',
154 DISCONNECTED: 'Disconnected',
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: 'Speed', value: `${latest.speed} km/h` },
167 { label: 'Altitude', value: `${latest.altitude} km` },
168 { label: 'Fuel', value: `${latest.fuel}%` },
169 { label: 'Temperature', value: `${latest.temperature} ยฐC` },
170 { label: 'Oxygen', value: `${latest.oxygen}%` },
171 ] : [];
172
173 return (
174 <div style={styles.panel}>
175 <div style={styles.panelHeader}>
176 <h3 style={styles.heading}>WebSocket - telemetry</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 }}>Connect</button>
186 <button onClick={disconnect} disabled={idle} style={{ ...styles.btnDanger, opacity: idle ? 0.4 : 1 }}>Disconnect</button>
187 <button onClick={simulateDrop} disabled={!connected} style={{ ...styles.btnDanger, opacity: connected ? 1 : 0.4 }}>Drop the link</button>
188 <button onClick={() => sendCommand('PING')} disabled={!connected} style={{ ...styles.btn, opacity: connected ? 1 : 0.4 }}>Send 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' }}>The server confirmed the {lastAck.command} command at {new Date(lastAck.timestamp).toLocaleTimeString('en')}</p>}
201 {!latest && idle && <p style={styles.empty}>Click "Connect" to start the data stream.</p>}
202 </div>
203 );
204}
205
206const EVENT_LABELS = { mission_update: 'mission', alert: 'alert', crew_message: 'crew', 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 // Close the subscription on unmount
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 ? 'Stop' : 'Subscribe'}
236 </button>
237 </div>
238 <div style={{ maxHeight: '300px', overflowY: 'auto' }}>
239 {events.length === 0 && <p style={styles.empty}>No notifications - click "Subscribe".</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}>Real-time Communication Hub</h1>
258 <p style={styles.subtitle}>WebSockets and Server-Sent Events in React</p>
259 <div style={styles.grid}>
260 <TelemetryPanel />
261 <SSENotificationsPanel />
262 </div>
263 <p style={styles.note}>A WebSocket carries data both ways: the server sends telemetry, and the client sends commands such as ping. "Drop the link" shows the reconnection. SSE flows only from the server to the client, so the notifications panel sends nothing.</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;Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. When is WebSocket a better choice than REST API in a React application?
2. How do Server-Sent Events (SSE) differ from WebSocket?
Hands-on tasks in the game
- Code editor
Finish the useMissionSocket hook that runs the comms channel to the station. The socket is created by createSocket(url) - in the preview it is a station simulator with the same interface as WebSocket. ___BLANK1___: in onmessage turn the text from event.data into a message object with JSON.parse. ___BLANK2___: the effect cleanup closes the socket with close() when the component goes away (also when StrictMode runs the effect twice). ___BLANK3___: send sends the object { text } turned into text with JSON.stringify. After a few seconds telemetry shows up in the preview, and every message you send comes back as an echo.