Building a useWebSocket React hook with TypeScript #
Eliminate WebSocket connection leaks and stale state desynchronization caused by React 18 StrictMode double-mounting and improper cleanup sequencing. This guide provides deterministic lifecycle management, exact diagnostic commands, and production-safe TypeScript patterns.
1. Symptom Identification: Double-Mount Leaks & State Desync #
Engineers deploying real-time dashboards frequently observe duplicate message event handlers firing and unexplained WebSocket is already in CLOSING or CLOSED state errors. Progressive heap growth during hot module replacement typically manifests when integrating React WebSocket Custom Hooks into complex component trees without strict lifecycle guards.
Diagnostic markers include readyState mismatches on rapid route transitions and orphaned socket instances visible in Chrome DevTools Memory snapshots. UI state reconciliation failures often occur after tab focus/blur cycles, reflecting outdated payloads.
Diagnostic Checklist:
- Verify
performance.getEntriesByType('resource')for duplicatews://connections per mount cycle. - Check
window.performance.memoryfor sustainedJSHeapUsedgrowth exceeding 15MB during navigation. - Confirm
ws.onmessagelistener count viaconsole.log(ws._listeners?.length)in development builds.
2. Root Cause Analysis: StrictMode Lifecycle & Cleanup Race Conditions #
The core failure stems from React 18 StrictMode intentionally invoking useEffect mount/unmount cycles twice in development. Instantiating a WebSocket directly inside useEffect without a stable useRef container creates a parallel connection before the first cleanup completes. This race condition bypasses standard ws.close() calls, leaving the initial socket in a zombie state.
Missing readyState validation before ws.send() triggers silent payload drops. As documented in broader Frontend Real-Time State Hooks & UI Patterns, the absence of an explicit connection state machine guarantees memory leaks and payload duplication in production-like environments.
Technical Breakdown:
- StrictMode double-invoke bypasses synchronous cleanup if
ws.close()is deferred to the microtask queue. - Missing
AbortControlleroruseReftracking prevents garbage collection of detached event listeners. - State updates triggered inside
onmessagewithoutuseSyncExternalStoreoruseReducerbatching cause torn reads.
3. Resolution: Production-Ready TypeScript Hook & Error Boundaries #
Implement a deterministic connection lifecycle using useRef for instance persistence, explicit readyState gating, and a synchronous cleanup routine. The following TypeScript hook enforces single-connection guarantees and integrates a React Error Boundary for network-level failures.
import { useEffect, useRef, useState, useCallback } from 'react';
export type WSStatus = 'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED' | 'ERROR';
export function useWebSocket<T = unknown>(url: string, options?: { reconnectInterval?: number }) {
const wsRef = useRef<WebSocket | null>(null);
const [status, setStatus] = useState<WSStatus>('CLOSED');
const [lastMessage, setLastMessage] = useState<T | null>(null);
const cleanup = useCallback(() => {
if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) {
wsRef.current.onclose = null;
wsRef.current.onerror = null;
wsRef.current.onmessage = null;
wsRef.current.close();
}
wsRef.current = null;
setStatus('CLOSED');
}, []);
useEffect(() => {
if (wsRef.current) return;
const ws = new WebSocket(url);
wsRef.current = ws;
setStatus('CONNECTING');
ws.onopen = () => setStatus('OPEN');
ws.onmessage = (e) => {
try {
setLastMessage(JSON.parse(e.data) as T);
} catch {
setLastMessage(e.data as T);
}
};
ws.onerror = () => setStatus('ERROR');
ws.onclose = () => {
cleanup();
if (options?.reconnectInterval) {
setTimeout(() => { wsRef.current = null; }, options.reconnectInterval);
}
};
return cleanup;
}, [url, options?.reconnectInterval, cleanup]);
return {
status,
lastMessage,
send: (data: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(data);
}
},
cleanup
};
}
import { Component, ErrorInfo, ReactNode } from 'react';
export class WebSocketErrorBoundary extends Component<{ children: ReactNode; fallback: ReactNode }, { hasError: boolean }> {
constructor(props: { children: ReactNode; fallback: ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() { return { hasError: true }; }
componentDidCatch(error: Error, info: ErrorInfo) { console.error('WS Boundary:', error, info); }
render() { return this.state.hasError ? this.props.fallback : this.props.children; }
}
Implementation Steps:
- Replace inline
new WebSocket()calls with theuseRef-gated initialization pattern. - Wrap consumer components in
<WebSocketErrorBoundary>to catch synchronous JSON parse failures. - Enforce
readyState === WebSocket.OPENchecks before allsend()invocations to preventInvalidStateError.
4. Prevention: CI/CD Validation & Runtime Guards #
Institutionalize leak prevention through static analysis and runtime telemetry. Configure ESLint to flag missing useEffect cleanup returns and enforce strict promise handling around async socket operations. Deploy a lightweight runtime interceptor that logs connection lifecycle transitions to your observability platform.
Integrate a Jest/Puppeteer leak test that mounts/unmounts the hook 50 times and asserts performance.memory.usedJSHeapSize delta remains under 2MB.
ESLint Configuration:
{
"rules": {
"react-hooks/exhaustive-deps": "error",
"@typescript-eslint/no-misused-promises": ["error", { "checksVoidReturn": false }],
"no-restricted-syntax": ["error", {
"selector": "CallExpression[callee.name='new WebSocket'][arguments.length=1]",
"message": "Use useWebSocket hook to prevent lifecycle leaks."
}]
}
}
CI Leak Test:
describe('WebSocket Leak Prevention', () => {
it('should not leak memory after 50 mount/unmount cycles', async () => {
const initialHeap = performance.memory.usedJSHeapSize;
for (let i = 0; i < 50; i++) {
const { unmount } = render(<TestComponent />);
unmount();
}
const finalHeap = performance.memory.usedJSHeapSize;
expect(finalHeap - initialHeap).toBeLessThan(2 * 1024 * 1024);
});
});
Monitoring Metrics:
- Track
ws_connections_activevsws_connections_leakedvia custom Prometheus counters. - Alert on
ws_reconnect_rate > 0.15per minute, indicating upstream instability or client-side state thrashing. - Enforce TypeScript strict mode (
strictNullChecks,noUncheckedIndexedAccess) to preventundefinedsocket access at compile time.