| |

React 117 ⚛️ Real-Time Applications with WebSockets and React

⏱️ Estimated read: 15 min | |

A WebSocket is a persistent, bidirectional connection between the browser and a server. Unlike HTTP, where every request opens a new connection and waits for a response, a WebSocket stays open and allows either side to send data at any time. React does not have a built-in WebSocket abstraction, and the browser’s WebSocket API is imperative and stateful. The work of integrating it into React’s declarative model—managing the connection lifecycle, handling reconnection, and cleaning up on unmount—falls on the developer.

This chapter covers the patterns and pitfalls of building real-time features with WebSockets in React. It begins with the custom hook that encapsulates the connection, the reason the socket belongs in a useRef rather than state, and the cleanup discipline that prevents leaks. It then addresses the StrictMode double-mount behavior, the reconnection strategy with exponential backoff, and the choice between the raw WebSocket API and libraries like Socket.IO. The final sections cover the useSyncExternalStore pattern for external real-time stores and the architectural constraints of scaling WebSocket servers.


Key point: A WebSocket connection is not a React value. It is an external resource with a lifecycle. The component that owns it must open it on mount, close it on unmount, and never recreate it on re-render. The useRef is not optional; it is the only correct place to store the socket.


Why WebSocket integration is not just an effect

The re-render problem. A WebSocket created inside the component body or inside a useEffect without proper dependency management will be created multiple times. Every re-render that triggers the effect opens a new connection, and the old one may not be closed. The result is duplicate messages, server-side connection exhaustion, and memory leaks. The socket must live in a useRef so that its identity is stable across renders, and the effect that creates it must have a dependency array that reflects when a new connection is actually needed .

The lifecycle problem. A WebSocket has states: connecting, open, closing, closed. React components have a lifecycle too. The mapping between them is not automatic. The hook must set up event handlers for onopen, onmessage, onerror, and onclose, and it must clean up by closing the socket and clearing any timers when the component unmounts. Without the cleanup, the socket outlives the component and continues consuming resources .

The StrictMode problem. React 18’s StrictMode deliberately mounts, unmounts, and remounts components in development to expose missing cleanup. A useEffect that opens a WebSocket runs twice, opening two connections. The second mount’s cleanup closes the first connection, but the second connection remains. If the cleanup is correct, this is harmless in development and does not happen in production . The fix is not to disable StrictMode; it is to ensure the cleanup closes the socket and clears any pending reconnection timers .

The reconnection problem. A WebSocket can close unexpectedly: network failure, server restart, idle timeout. A production application must reconnect. The naive onclose handler that immediately calls new WebSocket(url) creates a reconnection storm under a persistent outage. Exponential backoff—waiting 1 second, then 2, then 4, up to a cap—is the standard mitigation .


a. The custom hook and the useRef discipline

The hook pattern is the standard way to encapsulate WebSocket logic. It returns the connection status, the received messages, and a send function. The components that use it stay simple and do not know about the raw WebSocket API .

import { useState, useEffect, useRef, useCallback } from 'react';

export function useWebSocket(url: string) {
  const [messages, setMessages] = useState<string[]>([]);
  const [status, setStatus] = useState<'connecting' | 'open' | 'closed'>('connecting');
  const socketRef = useRef<WebSocket | null>(null);

  useEffect(() => {
    // Open the connection.
    const socket = new WebSocket(url);
    socketRef.current = socket;

    socket.onopen = () => setStatus('open');
    socket.onclose = () => setStatus('closed');
    socket.onerror = () => setStatus('closed');

    socket.onmessage = (event) => {
      // The message data is a string; parse if the protocol is JSON.
      setMessages((prev) => [...prev, event.data]);
    };

    // Cleanup: close the socket and clear the ref.
    // This runs on unmount and before the effect re-runs.
    return () => {
      socket.close();
      socketRef.current = null;
    };
  }, [url]);

  const send = useCallback((data: string | object) => {
    const socket = socketRef.current;
    if (socket?.readyState === WebSocket.OPEN) {
      socket.send(typeof data === 'string' ? data : JSON.stringify(data));
    }
  }, []);

  return { messages, status, send };
}

The socketRef holds the socket instance so that the send callback can access it without being recreated on every render. The useCallback with an empty dependency array ensures send is stable. The effect’s dependency array contains only url, so a new connection is opened only when the URL changes .

The cleanup function closes the socket. This is the single most important line in the hook. Without it, navigating away from the component leaves the connection open, and returning to the component opens a second connection while the first is still alive .


b. StrictMode, reconnection, and the production distinction

In development, StrictMode double-invokes the effect. The sequence is: mount, effect runs (opens socket A), unmount, cleanup runs (closes socket A), mount, effect runs (opens socket B). The component ends with one open socket, socket B. The double connection is transient and development-only. If the cleanup is missing, both sockets remain open, and the component receives duplicate messages .

The confusion arises when developers see two connections in the Network tab and conclude that StrictMode is broken. The correct response is to verify that the cleanup closes the socket. If it does, the double connection is expected and harmless. If the double connection persists in production, the problem is not StrictMode. It is a missing dependency array, a parent component that remounts the child, or a key prop that changes on every render .

Reconnection requires a timer and a flag to distinguish intentional closure from unexpected closure. The cleanup must set the flag and clear the timer, or the reconnection logic will attempt to reconnect after the component has unmounted .

useEffect(() => {
  let closedByUs = false;
  let reconnectTimer: ReturnType<typeof setTimeout>;

  const connect = () => {
    const socket = new WebSocket(url);
    socketRef.current = socket;

    socket.onclose = () => {
      if (closedByUs) return;
      // Exponential backoff: 1s, 2s, 4s, ... capped at 30s.
      const delay = Math.min(30000, 1000 * 2 ** attempt++);
      reconnectTimer = setTimeout(connect, delay);
    };
  };

  connect();

  return () => {
    closedByUs = true;
    clearTimeout(reconnectTimer);
    socketRef.current?.close();
  };
}, [url]);

The closedByUs flag prevents the cleanup’s close() call from triggering a reconnection. The clearTimeout prevents a scheduled reconnection from firing after unmount. Both are necessary for a correct implementation .

The choice between raw WebSocket and Socket.IO is a trade-off. Socket.IO adds automatic reconnection, fallback transports, and a room abstraction, at the cost of a larger bundle and a non-standard protocol. For applications that need these features and control both the client and server, Socket.IO is often the pragmatic choice. For applications that need a standard WebSocket and want to control the reconnection logic themselves, the raw API plus a custom hook is sufficient .


c. useSyncExternalStore and the external store pattern

A WebSocket is an external store. It holds state (the connection status, the last message) that exists outside React’s component tree. The useEffect plus useState pattern works, but it has a subtle flaw under concurrent rendering: the state can be read inconsistently across components, a phenomenon called tearing. React 18 introduced useSyncExternalStore specifically for this case .

The hook takes a subscribe function, a getSnapshot function, and an optional getServerSnapshot. The subscribe function attaches a listener to the WebSocket’s message event and returns an unsubscribe function. The getSnapshot function returns the current value synchronously .

import { useSyncExternalStore } from 'react';

// A module-level store that the WebSocket writes to.
let currentMessage: string | null = null;
const listeners = new Set<() => void>();

function subscribe(callback: () => void) {
  listeners.add(callback);
  return () => listeners.delete(callback);
}

function getSnapshot() {
  return currentMessage;
}

function notify() {
  listeners.forEach((l) => l());
}

// The WebSocket updates the store and notifies.
socket.onmessage = (event) => {
  currentMessage = event.data;
  notify();
};

// The component subscribes.
function LatestMessage() {
  const message = useSyncExternalStore(subscribe, getSnapshot);
  return <p>{message}</p>;
}

This pattern is the recommended approach for libraries that wrap real-time connections, including Tiptap’s Hocuspocus provider, which uses useSyncExternalStore internally to expose connection and synchronization state to React components . It eliminates tearing, handles server rendering correctly, and avoids the redundant renders that useState can cause .

The trade-off is complexity. For a single component that displays messages, the useEffect hook is simpler and sufficient. For a connection shared across many components, or for a library that exposes real-time state, useSyncExternalStore is the correct foundation.


Complete Example Session

// ============================================
// PART 1: THE HOOK WITH RECONNECTION
// ============================================
// The socket in a ref, the cleanup closes it,
// and reconnection uses exponential backoff.

import { useState, useEffect, useRef, useCallback } from 'react';

type Status = 'connecting' | 'open' | 'closed';

function useRealtime(url: string) {
  const [status, setStatus] = useState<Status>('connecting');
  const [messages, setMessages] = useState<string[]>([]);
  const socketRef = useRef<WebSocket | null>(null);

  useEffect(() => {
    let closedByUs = false;
    let attempt = 0;
    let timer: ReturnType<typeof setTimeout>;

    const connect = () => {
      setStatus('connecting');
      const socket = new WebSocket(url);
      socketRef.current = socket;

      socket.onopen = () => {
        attempt = 0;
        setStatus('open');
      };
      socket.onclose = () => {
        setStatus('closed');
        if (closedByUs) return;
        const delay = Math.min(30000, 1000 * 2 ** attempt++);
        timer = setTimeout(connect, delay);
      };
      socket.onerror = () => socket.close();
      socket.onmessage = (event) => {
        setMessages((prev) => [...prev, event.data]);
      };
    };

    connect();

    return () => {
      closedByUs = true;
      clearTimeout(timer);
      socketRef.current?.close();
      socketRef.current = null;
    };
  }, [url]);

  const send = useCallback((data: string | object) => {
    const socket = socketRef.current;
    if (socket?.readyState === WebSocket.OPEN) {
      socket.send(typeof data === 'string' ? data : JSON.stringify(data));
    }
  }, []);

  return { status, messages, send };
}

// ============================================
// PART 2: THE COMPONENT
// ============================================
// The component renders the status and the messages,
// and disables the send button when not connected.

function Chat({ url }: { url: string }) {
  const { status, messages, send } = useRealtime(url);
  const [input, setInput] = useState('');

  return (
    <div>
      <p>Status: {status}</p>
      <ul>
        {messages.map((m, i) => (
          <li key={i}>{m}</li>
        ))}
      </ul>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        disabled={status !== 'open'}
      />
      <button
        onClick={() => {
          send(input);
          setInput('');
        }}
        disabled={status !== 'open'}
      >
        Send
      </button>
    </div>
  );
}

// ============================================
// PART 3: THE STRICTMODE BEHAVIOR
// ============================================
// In development, StrictMode mounts, unmounts,
// and remounts. The cleanup closes the first socket.
// The second socket is the one that remains.

// The double connection in the Network tab is
// expected and harmless if the cleanup is correct.

// ============================================
// PART 4: THE PRODUCTION CHECK
// ============================================
// If duplicate connections appear in production:
// 1. The effect has no dependency array.
// 2. The parent remounts the child.
// 3. A key prop changes.
// 4. The cleanup does not close the socket.

// ============================================
// PART 5: THE useSyncExternalStore VARIANT
// ============================================
// For a shared connection across components,
// use an external store with useSyncExternalStore.

import { useSyncExternalStore } from 'react';

// Module-level store.
let latest: string | null = null;
const listeners = new Set<() => void>();

function subscribe(cb: () => void) {
  listeners.add(cb);
  return () => listeners.delete(cb);
}

function getSnapshot() {
  return latest;
}

function notify() {
  listeners.forEach((l) => l());
}

// The socket writes to the store.
function connectStore(url: string) {
  const socket = new WebSocket(url);
  socket.onmessage = (e) => {
    latest = e.data;
    notify();
  };
  return socket;
}

// Components subscribe.
function Latest() {
  const message = useSyncExternalStore(subscribe, getSnapshot);
  return <p>{message}</p>;
}

// ============================================
// PART 6: THE SOCKET.IO ALTERNATIVE
// ============================================
// Socket.IO handles reconnection, fallback, and rooms.
// It is a different protocol, not a drop-in replacement
// for the raw WebSocket API.

// import { io } from 'socket.io-client';
// const socket = io('http://localhost:3000');
// socket.on('connect', () => { ... });
// socket.on('message', (data) => { ... });

// ============================================
// PART 7: THE SERVER SIDE
// ============================================
// A React app cannot host a WebSocket server.
// The server is a separate process, typically Node.js
// with the ws package or Socket.IO.

// const wss = new WebSocketServer({ port: 8080 });
// wss.on('connection', (socket) => {
//   socket.on('message', (data) => {
//     // broadcast to all clients
//   });
// });

// ============================================
// PART 8: THE SCALING CONSTRAINT
// ============================================
// A single server holds all connections in memory.
// For multiple server instances, a pub/sub layer
// (Redis) broadcasts messages across instances.

// ============================================
// PART 9: THE AUTHENTICATION PATTERN
// ============================================
// Browsers cannot set custom headers on WebSocket.
// Authentication is via query parameter or
// the first message after connection.

// const socket = new WebSocket(`${url}?token=${token}`);

// ============================================
// PART 10: THE FULL PICTURE
// ============================================
// The socket lives in a ref, the effect owns its
// lifecycle, and the cleanup closes it.

export default function App() {
  return <Chat url="wss://example.com/ws" />;
}

The ten parts build a complete hook with reconnection and cleanup, the component that uses it, the StrictMode explanation, the useSyncExternalStore alternative, and the server-side and scaling constraints. The key observation is that the hook is the unit of encapsulation, and the cleanup is the unit of correctness.


Quick Reference

Hook Structure

ElementPurpose
useRefHold the socket across renders
useEffectOpen, attach handlers, cleanup
useCallbackStable send function
useStateStatus and received messages

Connection States

StateMeaning
connectingHandshake in progress
openReady to send and receive
closingClose handshake in progress
closedConnection terminated

Reconnection Strategy

StrategyBehavior
ImmediateStorms under outage
Fixed delaySimple, but slow recovery
Exponential backoff1s, 2s, 4s, capped at 30s
JitterAdds randomness to avoid thundering herd

Raw WebSocket vs Socket.IO

FeatureRaw WebSocketSocket.IO
ProtocolStandardCustom
ReconnectionManualAutomatic
FallbackNoneLong-polling
RoomsManualBuilt-in
BundleSmallLarger

Best Practices

✅ Do This:

// Store the socket in a ref.
const socketRef = useRef<WebSocket | null>(null);                    // ✅

// Close in the cleanup.
return () => socketRef.current?.close();                             // ✅

// Clear reconnection timers in the cleanup.
clearTimeout(timer);                                                 // ✅

// Check readyState before sending.
if (socket.readyState === WebSocket.OPEN) socket.send(data);         // ✅

// Use exponential backoff for reconnection.
const delay = Math.min(30000, 1000 * 2 ** attempt++);                // ✅

❌ Don’t Do This:

// Don't create the socket in the component body.
const socket = new WebSocket(url); // new socket every render       // ❌

// Don't omit the cleanup.
useEffect(() => { new WebSocket(url); }, []); // leak                // ❌

// Don't reconnect on every close without backoff.
socket.onclose = () => new WebSocket(url);                           // ❌

// Don't assume StrictMode causes production duplicates.
// StrictMode is development-only.                                   // ❌

// Don't try to set custom headers on WebSocket.
new WebSocket(url, { headers: {} }); // not supported                // ❌

Common Pitfalls

PitfallWhy It HappensFix
Duplicate connections in devStrictMode double-mountCorrect cleanup closes the first socket
Duplicate connections in prodMissing dependency array or remounting parentCheck effect deps and key props
Messages not receivedHandler attached after messages arriveAttach onmessage immediately after construction
Memory leakCleanup missingClose socket in return function
Reconnection stormImmediate reconnect on closeExponential backoff
Cannot authenticateCustom headers unsupportedUse query parameter or first message

Real-World Examples

1. Basic Hook

const socket = new WebSocket(url);
return () => socket.close();

2. Send Function

if (socket.readyState === WebSocket.OPEN) socket.send(data);

3. Parse JSON

socket.onmessage = (e) => setMessages((p) => [...p, JSON.parse(e.data)]);

4. Reconnection with Backoff

const delay = Math.min(30000, 1000 * 2 ** attempt++);
timer = setTimeout(connect, delay);

5. StrictMode Cleanup

return () => { closedByUs = true; clearTimeout(timer); socket.close(); };

6. Status Display

<p>{status === 'open' ? 'Connected' : 'Disconnected'}</p>

7. Disable Send When Closed

<button disabled={status !== 'open'}>Send</button>

8. useSyncExternalStore Subscribe

useSyncExternalStore(subscribe, getSnapshot);

9. Query Token

new WebSocket(`${url}?token=${token}`);

10. Server Broadcast

wss.clients.forEach((c) => c.readyState === OPEN && c.send(data));

Visual

The Effect Lifecycle

┌─────────────────────────────────────────────────────────┐
│  MOUNT                                                  │
│         │                                               │
│         ▼                                               │
│  useEffect runs                                         │
│  → new WebSocket(url)                                   │
│  → socketRef.current = socket                           │
│  → attach onopen, onmessage, onclose, onerror           │
│         │                                               │
│         ▼                                               │
│  RENDER (status, messages update on events)             │
│         │                                               │
│         ▼                                               │
│  UNMOUNT (or url changes)                               │
│         │                                               │
│         ▼                                               │
│  cleanup runs                                           │
│  → closedByUs = true                                    │
│  → clearTimeout(timer)                                  │
│  → socket.close()                                       │
│  → socketRef.current = null                             │
└─────────────────────────────────────────────────────────┘

StrictMode Double-Mount

┌─────────────────────────────────────────────────────────┐
│  DEVELOPMENT (StrictMode)                               │
│                                                         │
│  mount    → effect runs → socket A open                 │
│  unmount  → cleanup runs → socket A closed              │
│  mount    → effect runs → socket B open                 │
│                                                         │
│  Result: one open socket (B). Brief overlap.           │
│  This is intentional.                                   │
│                                                         │
│  ─────────────────────────────────────────────────────  │
│                                                         │
│  PRODUCTION (no StrictMode)                             │
│                                                         │
│  mount    → effect runs → socket A open                 │
│  (no unmount/remount)                                   │
│                                                         │
│  Result: one open socket (A).                           │
│                                                         │
│  If two sockets persist in production, the cause is     │
│  a missing dependency array or a remounting parent.     │
└─────────────────────────────────────────────────────────┘

Reconnection Backoff

┌─────────────────────────────────────────────────────────┐
│  attempt 0: close → wait 1s  → connect                  │
│  attempt 1: close → wait 2s  → connect                  │
│  attempt 2: close → wait 4s  → connect                  │
│  attempt 3: close → wait 8s  → connect                  │
│  attempt 4: close → wait 16s → connect                  │
│  attempt 5: close → wait 30s → connect (capped)         │
│                                                         │
│  The cap prevents unbounded waiting.                    │
│  A successful connection resets attempt to 0.           │
└─────────────────────────────────────────────────────────┘

The External Store Pattern

┌─────────────────────────────────────────────────────────┐
│  WebSocket (outside React)                              │
│         │                                               │
│         ▼  onmessage                                    │
│  Store: { latest, listeners }                           │
│         │                                               │
│         ▼  notify()                                     │
│  useSyncExternalStore(subscribe, getSnapshot)           │
│         │                                               │
│         ▼                                               │
│  Component renders the snapshot                         │
│                                                         │
│  No tearing. No useEffect. No manual state sync.        │
└─────────────────────────────────────────────────────────┘

Summary

ItemValue
Socket storageuseRef
LifecycleuseEffect with cleanup
Cleanupsocket.close(), clearTimeout()
Send guardreadyState === WebSocket.OPEN
ReconnectionExponential backoff
StrictModeDouble-mount in dev; cleanup makes it harmless
External storeuseSyncExternalStore
ServerSeparate process (Node.js ws or Socket.IO)
ScalingPub/sub (Redis) for multi-instance
AuthQuery parameter or first message

Key takeaways:

  • The socket belongs in a useRef. A socket created in the component body or without a stable ref is recreated on every render. The ref gives it a stable identity across renders .
  • The cleanup is the correctness unit. Closing the socket and clearing timers in the effect’s return function prevents leaks, duplicate connections, and reconnection after unmount .
  • StrictMode double-mounts in development only. The first socket is closed by the cleanup, and the second remains. This is intentional. If duplicates persist in production, the cause is a missing dependency array or a remounting parent .
  • Reconnection needs backoff and a flag. Exponential backoff prevents storms. The closedByUs flag prevents the cleanup’s close() from triggering a reconnection .
  • useSyncExternalStore is for shared external state. For a connection that many components subscribe to, the external store pattern eliminates tearing and avoids redundant renders .
  • Socket.IO is a different tool, not a drop-in replacement. It provides automatic reconnection, fallback, and rooms, but it uses a custom protocol and a larger bundle .
  • The server is separate. A React app cannot host a WebSocket server. The server is a Node.js process or another runtime that accepts connections and broadcasts messages.
  • Scaling requires pub/sub. A single server holds all connections in memory. Multiple instances need Redis or another pub/sub layer to broadcast across instances.

Remember: A WebSocket is a resource with a lifecycle, not a React value. The hook that owns it must open it when the component mounts, close it when the component unmounts, and never recreate it on re-render. The useRef holds the socket, the useEffect manages the lifecycle, and the cleanup is the line that prevents the leak. StrictMode will open two connections in development, and that is correct behavior if the cleanup closes the first. Reconnection needs a timer, a backoff, and a flag. For a shared connection across many components, the external store pattern is the correct foundation. Build the hook, close the socket, and let the component render the state.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!