JavaScript and React course ยท Module 10: React Ecosystem and Future

WebSockets and Server-Sent Events in React

18 min read
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. 1. When is WebSocket a better choice than REST API in a React application?

  2. 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.

Useful articles