WebSocket hook with automatic reconnection, exponential backoff, and state handling.
System design context: WebSocket connections are inherently fragile — network drops, server restarts, and proxy timeouts can disconnect the client at any time. A production WebSocket implementation must handle:
1import { useEffect, useRef, useCallback, useState } from "react";23interface UseWebSocketOptions {4 url: string;5 onMessage?: (data: unknown) => void;6 onOpen?: () => void;7 onClose?: () => void;8 reconnectAttempts?: number;9 reconnectInterval?: number;10 heartbeatInterval?: number;11}1213type ConnectionStatus = "connecting" | "connected" | "disconnected" | "reconnecting";1415function useWebSocket({16 url,17 onMessage,18 onOpen,19 onClose,20 reconnectAttempts = 5,21 reconnectInterval = 1000,22 heartbeatInterval = 3000023}: UseWebSocketOptions) {24 const wsRef = useRef<WebSocket | null>(null);25 const [status, setStatus] = useState<ConnectionStatus>("disconnected");26 const [lastMessage, setLastMessage] = useState<unknown>(null);27 const retryCountRef = useRef(0);28 const onMessageRef = useRef(onMessage);29 const heartbeatRef = useRef<NodeJS.Timeout | null>(null);30 const mountedRef = useRef(true);31 onMessageRef.current = onMessage;3233 const connect = useCallback(() => {34 if (!mountedRef.current) return;3536 setStatus(prev => prev === "reconnecting" ? "reconnecting" : "connecting");37 const ws = new WebSocket(url);38 wsRef.current = ws;3940 ws.onopen = () => {41 if (!mountedRef.current) return;42 setStatus("connected");43 retryCountRef.current = 0;44 onOpen?.();4546 // Start heartbeat47 heartbeatRef.current = setInterval(() => {48 if (ws.readyState === WebSocket.OPEN) {49 ws.send(JSON.stringify({ type: "ping" }));50 }51 }, heartbeatInterval);52 };5354 ws.onmessage = (event) => {55 if (!mountedRef.current) return;56 try {57 const data = JSON.parse(event.data);58 if (data.type !== "pong") {59 setLastMessage(data);60 onMessageRef.current?.(data);61 }62 } catch {63 setLastMessage(event.data);64 onMessageRef.current?.(event.data);65 }66 };6768 ws.onclose = () => {69 if (!mountedRef.current) return;70 setStatus("disconnected");71 if (heartbeatRef.current) clearInterval(heartbeatRef.current);72 onClose?.();7374 // Exponential backoff reconnection75 if (retryCountRef.current < reconnectAttempts) {76 retryCountRef.current++;77 const delay = reconnectInterval * Math.pow(2, retryCountRef.current - 1);78 setStatus("reconnecting");79 setTimeout(connect, Math.min(delay, 30000));80 }81 };8283 ws.onerror = () => {84 ws.close();85 };86 }, [url, reconnectAttempts, reconnectInterval, heartbeatInterval, onOpen, onClose]);8788 useEffect(() => {89 mountedRef.current = true;90 connect();91 return () => {92 mountedRef.current = false;93 if (heartbeatRef.current) clearInterval(heartbeatRef.current);94 wsRef.current?.close();95 };96 }, [connect]);9798 const send = useCallback((data: unknown) => {99 if (wsRef.current?.readyState === WebSocket.OPEN) {100 wsRef.current.send(JSON.stringify(data));101 }102 }, []);103104 const reconnect = useCallback(() => {105 wsRef.current?.close();106 retryCountRef.current = 0;107 connect();108 }, [connect]);109110 return { status, lastMessage, send, reconnect };111}112113// Usage114function Chat() {115 const [messages, setMessages] = useState<{ id: number; text: string }[]>([]);116 const { status, send, reconnect } = useWebSocket({117 url: "wss://chat.example.com",118 onMessage: (msg) => setMessages(prev => [...prev, msg as { id: number; text: string }])119 });120121 return (122 <div>123 <div className="flex items-center gap-2 mb-4">124 <span className={125 "w-2 h-2 rounded-full " +126 (status === "connected" ? "bg-green-500" : "bg-red-500")127 } />128 <span>{status}</span>129 {status === "disconnected" && (130 <button onClick={reconnect}>Reconnect</button>131 )}132 </div>133 <div className="space-y-2">134 {messages.map((m, i) => <p key={i}>{m.text}</p>)}135 </div>136 <button onClick={() => send({ text: "Hello!" })}>Send</button>137 </div>138 );139}
Production pitfalls and fixes:
useEffect cleanup.connect function with useCallback.JSON.parse in a try/catch.Scaling pattern: For high-traffic apps, consider using a message queue (e.g., Redis pub/sub) on the server side and connection pooling to distribute load across multiple WebSocket servers.
Monitoring: Track connection uptime, reconnection frequency, and message throughput. Use Sentry or Datadog to alert on repeated reconnection failures.