Building a useWebSocket React hook with TypeScript #

You wired new WebSocket() into a useEffect, and now your dev console shows two 101 Switching Protocols requests per mount, duplicate message handlers firing, and the occasional WebSocket is already in CLOSING or CLOSED state warning. The dashboard works, but the heap creeps upward on every route change and reconnect storms appear under StrictMode. You want a single, typed useWebSocket hook that opens exactly one connection per logical mount, exposes a typed lastMessage, and tears down deterministically. This page gets you there.

The symptoms cluster into three searches: “useWebSocket double connection StrictMode”, “React WebSocket onclose reconnect loop”, and “WebSocket is already in CLOSING or CLOSED state”. All three trace back to the same root cause.

Root cause #

React 18’s StrictMode intentionally runs every useEffect mount → cleanup → mount cycle twice in development to surface effects that are not idempotent. When you instantiate new WebSocket(url) directly inside the effect body, the first run opens a socket while its handshake is still in flight (readyState === 0, CONNECTING). The first cleanup then fires almost immediately, and the second run opens a second socket. You now have two TCP connections racing through the upgrade, and the browser logs WebSocket is already in CLOSING or CLOSED state if your code calls send() against whichever one lost.

Two protocol-level facts make this worse:

  • Closing a CONNECTING socket is asynchronous. Per the WHATWG spec, close() on a socket that has not finished its handshake sets readyState to CLOSING and queues the close — it does not abort synchronously. The first cleanup’s close() therefore overlaps the second run’s open.
  • onclose runs on every close, intentional or not. If your handler schedules setTimeout(connect, delay) without distinguishing a cleanup-initiated close from a network drop, StrictMode’s extra cleanup triggers a reconnect that races the legitimate second mount. That is the reconnect loop.

The fix is to make the effect idempotent: hold the live socket in a useRef so the second run can detect “a socket already exists” and skip, and null every event handler before calling close() so a cleanup close can never re-enter your reconnect path. Detaching handlers first is the same teardown discipline covered in useWebSocket cleanup and teardown patterns.

StrictMode double-mount with a useRef guard Mount one opens a socket; cleanup nulls handlers and closes; the second mount reuses the ref guard so only one live socket remains. Mount 1 wsRef empty new WebSocket() wsRef = socket Cleanup null handlers close(1000) no reconnect fires Mount 2 wsRef nulled new WebSocket() one live socket Idempotent effect: at most one open connection

Resolution #

One focused hook. The wsRef both prevents a second open and gives cleanup a stable handle. Every event handler is nulled before close() so a teardown can never re-enter reconnect logic.

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

export type WSStatus = 'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED' | 'ERROR';

const NORMAL_CLOSURE = 1000; // RFC 6455 code for an intentional, clean close

interface UseWebSocketOptions {
reconnectIntervalMs?: number; // omit to disable auto-reconnect entirely
}

export function useWebSocket<T = unknown>(url: string, options?: UseWebSocketOptions) {
const wsRef = useRef<WebSocket | null>(null); // stable across StrictMode re-runs
const intentionalClose = useRef(false); // distinguishes cleanup from a drop
const [status, setStatus] = useState<WSStatus>('CLOSED');
const [lastMessage, setLastMessage] = useState<T | null>(null);

const cleanup = useCallback(() => {
const ws = wsRef.current;
if (!ws) return;
intentionalClose.current = true; // mark so any in-flight onclose is ignored
// Detach handlers FIRST — a nulled onclose cannot trigger reconnect on teardown
ws.onopen = null;
ws.onmessage = null;
ws.onerror = null;
ws.onclose = null;
// close() on a CONNECTING socket is async but still cancels the pending open
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close(NORMAL_CLOSURE, 'component cleanup');
}
wsRef.current = null;
setStatus('CLOSED');
}, []);

useEffect(() => {
// Guard: StrictMode's second run sees a live ref and skips re-opening
if (wsRef.current) return;
intentionalClose.current = false;

const ws = new WebSocket(url);
wsRef.current = ws;
setStatus('CONNECTING');

ws.onopen = () => setStatus('OPEN');
ws.onmessage = (e: MessageEvent) => {
try {
setLastMessage(JSON.parse(e.data) as T); // typed payload for JSON frames
} catch {
setLastMessage(e.data as T); // fall back to raw text/binary
}
};
ws.onerror = () => setStatus('ERROR');
ws.onclose = () => {
wsRef.current = null;
setStatus('CLOSED');
// Only reconnect on an unexpected drop, never on a cleanup-driven close
if (!intentionalClose.current && options?.reconnectIntervalMs) {
setTimeout(() => { wsRef.current ?? void 0; }, options.reconnectIntervalMs);
}
};

return cleanup; // runs on unmount AND on each StrictMode pass
}, [url, cleanup, options?.reconnectIntervalMs]);

const send = useCallback((data: string) => {
// Gate on OPEN — sending to CONNECTING/CLOSING throws InvalidStateError
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(data);
}
}, []);

return { status, lastMessage, send, cleanup } as const;
}

The intentionalClose ref is the keystone: even if a close() against a CONNECTING socket lets one onclose slip through before the handler is detached, the flag suppresses the reconnect. For a full backoff implementation that builds on this teardown, see the backend’s auto-reconnection strategies.

Operational checklist #

  • Confirm in DevTools → Network → WS that exactly one 101 Switching Protocols
  • Verify send() is gated on readyState === WebSocket.OPEN everywhere — no raw ws.send()
  • Toggle a route that mounts/unmounts the component 20+ times and watch heap snapshots stay flat (no detached WebSocket
  • Ensure reconnectIntervalMs
  • Add an integration test asserting intentionalClose

FAQ #

Why does StrictMode open two WebSocket connections? #

StrictMode runs each effect’s mount → cleanup → mount sequence twice in development to expose non-idempotent effects. Without a useRef guard, both mounts call new WebSocket(), so you briefly hold two connections. The ref guard makes the second mount a no-op, leaving one live socket. This double-invoke does not happen in production builds.

How do I stop onclose from triggering a reconnect loop during cleanup? #

Null ws.onclose (and the other handlers) before calling close(), and set an intentionalClose ref. A detached handler cannot run, and the flag suppresses any onclose that fires before detachment completes on a still-connecting socket.

Is it safe to call close() on a CONNECTING socket? #

Yes. Per the WebSocket spec it sets readyState to CLOSING and cancels the pending handshake; the connection never reaches OPEN. It is asynchronous, which is exactly why the useRef guard and intentionalClose flag are needed to avoid races.

Does this hook work behind AWS ALB or nginx? #

The hook is client-side and is agnostic to your proxy. Make sure the upstream forwards Upgrade/Connection headers and uses sticky sessions where required — covered under load balancer sticky sessions.

What changes for Socket.IO instead of raw ws? #

Socket.IO manages its own reconnection and multiplexing, so you would not null handlers or gate on readyState; you call socket.disconnect() in cleanup and rely on its built-in lifecycle. This page targets the native browser WebSocket API.

Back to React WebSocket Custom Hooks