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 duplicate ws:// connections per mount cycle.
  • Check window.performance.memory for sustained JSHeapUsed growth exceeding 15MB during navigation.
  • Confirm ws.onmessage listener count via console.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 AbortController or useRef tracking prevents garbage collection of detached event listeners.
  • State updates triggered inside onmessage without useSyncExternalStore or useReducer batching 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:

  1. Replace inline new WebSocket() calls with the useRef-gated initialization pattern.
  2. Wrap consumer components in <WebSocketErrorBoundary> to catch synchronous JSON parse failures.
  3. Enforce readyState === WebSocket.OPEN checks before all send() invocations to prevent InvalidStateError.

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_active vs ws_connections_leaked via custom Prometheus counters.
  • Alert on ws_reconnect_rate > 0.15 per minute, indicating upstream instability or client-side state thrashing.
  • Enforce TypeScript strict mode (strictNullChecks, noUncheckedIndexedAccess) to prevent undefined socket access at compile time.