React WebSocket Custom Hooks #

A WebSocket is a long-lived, stateful transport, and React’s render model is hostile to long-lived state. The mismatch shows up in a specific, repeatable failure: you mount a component that opens a socket, React 18 StrictMode immediately unmounts and remounts it in development, and now you have two sockets where you wanted one. In production the same bug surfaces during fast route changes — a user clicks through three pages in a second and you leak three connections, each still firing onmessage into a setState on a component that no longer exists. The console fills with “Can’t perform a React state update on an unmounted component”, memory climbs, and your backend’s ws_connections_active gauge drifts upward with nobody on the other end.

This page covers the hook patterns that make a raw WebSocket behave correctly inside the React lifecycle: keeping the socket instance off the render path, wiring listeners so they never outlive the component, and exposing a stable API that does not retrigger effects on every render. Get these right once and every downstream concern — reconnection, buffering, optimistic updates — has a clean foundation to build on.

Prerequisites #

Before building these hooks, you need the surrounding pieces in place:

  • A backend that speaks raw WebSocket framing. The patterns here use the browser-native WebSocket API, which aligns with the server lifecycle described in Frontend WebSocket State Hooks & UI Patterns.
  • React 18 or 19 with StrictMode enabled in development. If your effects are not double-invoke safe, you will not discover it until production — so test with StrictMode on.
  • A teardown discipline that nulls handlers before closing. The detailed teardown rules live in Memory Leak Prevention; this page assumes you will apply them.
  • TypeScript 5.x. Every example below is typed, and the message-routing pattern depends on discriminated unions.

How a hook owns the socket across the React lifecycle #

The hardest concept to hold in your head is ownership: which render owns the socket, and when does that ownership transfer. The diagram below traces one mount/unmount/remount cycle under StrictMode and shows where the guard and the teardown have to fire.

useWebSocket lifecycle under StrictMode Mount runs the effect and opens a socket; StrictMode unmount tears it down; remount opens a fresh socket, with a ref guard preventing duplicates. React render commits the component useRef holds the socket; useState holds only the status Mount effect runs, open socket attach onmessage Cleanup null handlers first then close(1000) Remount fresh socket, no leak ref guard blocks dupes StrictMode runs mount, cleanup, then remount in dev A missed cleanup leaks one socket per unmount

The rule the diagram encodes: state that the UI reads goes through useState; the socket itself never does. A socket in component state would force a re-render — and a teardown — on every status change, recreating the very transport you are trying to keep stable.

Core implementation #

The foundational hook keeps the socket in a useRef, exposes connection status as the only piece of useState, and routes inbound frames through a typed handler. Every non-obvious line is annotated. The canonical, fully-featured version is broken down step by step in Building a useWebSocket React hook with TypeScript.

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

export type ConnectionStatus = 'connecting' | 'open' | 'closing' | 'closed';

// Discriminated union: the `type` field lets the consumer narrow safely.
type ServerMessage =
| { type: 'chat'; body: string; at: number }
| { type: 'presence'; userId: string; online: boolean };

interface UseWebSocketOptions {
protocols?: string | string[];
onMessage?: (msg: ServerMessage) => void; // called per inbound frame
}

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

export function useWebSocket(url: string, options?: UseWebSocketOptions) {
const wsRef = useRef<WebSocket | null>(null);
// Keep the latest callback in a ref so changing it never retriggers the effect.
const onMessageRef = useRef(options?.onMessage);
onMessageRef.current = options?.onMessage;

const [status, setStatus] = useState<ConnectionStatus>('closed');

useEffect(() => {
const ws = new WebSocket(url, options?.protocols);
wsRef.current = ws;
setStatus('connecting');

ws.onopen = () => setStatus('open');
ws.onclose = () => setStatus('closed');
ws.onerror = () => ws.close(); // surface errors through the close path
ws.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data) as ServerMessage;
onMessageRef.current?.(parsed); // read latest callback, not a stale closure
} catch {
// Drop unparseable frames rather than throwing inside the event loop.
}
};

return () => {
// Null handlers BEFORE close so the teardown never fires onclose/onmessage
// into an unmounted component.
ws.onopen = ws.onclose = ws.onerror = ws.onmessage = null;
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close(NORMAL_CLOSURE, 'component unmount');
}
wsRef.current = null;
};
}, [url]); // protocols intentionally omitted; pass a stable string to avoid reconnect churn

const send = useCallback((data: unknown) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
return true;
}
return false; // caller decides whether to buffer or drop
}, []);

return { status, send } as const;
}

Two design choices carry their weight here. First, the message callback lives in onMessageRef, not the effect dependency array — so a consumer passing an inline arrow function does not tear down and rebuild the socket on every render. Second, send returns a boolean instead of buffering internally; that keeps the core hook small and lets buffering be an explicit, composable layer when you need it.

Configuration reference #

Parameter Type Default Production value Notes
url string — (required) wss://… Must be wss:// in production; the effect re-runs if it changes
protocols string | string[] undefined stable string Inline arrays change identity each render — memoize or pass a constant
close code number 1000 1000 Use 1000 for clean unmount; reserve 4001/1008 for auth/policy
onMessage (m) => void undefined held in a ref Never put in deps; ref pattern avoids reconnect churn
reconnect backoff base number (ms) n/a here 5001000 Lives in the resilient layer, not the core hook
max reconnect attempts number n/a here 58 Cap retries to avoid hammering a down backend

Edge cases & gotchas #

  • StrictMode double-invoke. React 18+ runs mount → cleanup → mount in development. The fix is not a wsRef.current “already open” guard — that guard is fragile because it skips the second mount’s real socket. The correct fix is a complete cleanup that closes the first socket fully, so the second mount opens cleanly. The code above does exactly that.
  • Stale closures in onmessage. If you read props or state directly inside the onmessage handler defined in the effect, you capture the values from the render that created the socket. By the time a frame arrives, those values may be stale. Route through a ref (as with onMessageRef) or a reducer dispatch, which is identity-stable.
  • onerror does not give you a reason. The browser fires error with no diagnostic detail for security reasons. Do not try to branch on it — let onerror trigger close() and read the close code/reason in onclose, which is where the actionable signal lives.
  • Changing url mid-flight. A new url re-runs the effect, which tears down the old socket and opens a new one. That is correct, but if url is derived inline (e.g. with a query string built each render) you will reconnect constantly. Memoize the URL.

Verification #

Confirm the hook opens exactly one socket per logical connection and closes it on unmount.

# Count established WebSocket connections from the browser process to your backend.
# Run before and after navigating away — the count must return to baseline.
ss -tnp | grep ':443' | grep -c node

In Chrome DevTools, open the Network tab, filter to WS, and watch the connection list:

  • Mount a component once. You should see exactly one WS entry move to status 101.
  • Navigate away. The entry’s frames stop and the connection closes with code 1000.
  • In StrictMode dev builds you will briefly see a connect/disconnect/connect sequence — that is the double-invoke, and a leak shows up as a socket that never closes.

Assert against your backend gauge after a load test that mounts and unmounts many clients:

# The active-connection count must return to its pre-test baseline once clients unmount.
curl -s localhost:9090/metrics | grep ws_connections_active

Guides in this area #

FAQ #

Why use useRef instead of useState for the WebSocket instance? #

A socket stored in useState would trigger a re-render every time you needed to mutate it, and React would treat each render as a reason to potentially tear down and recreate the connection. useRef gives you a stable, mutable container that persists across renders without participating in the render cycle — exactly the semantics a long-lived transport needs. State is reserved for the status (open, closed), which the UI does need to react to.

How do I make the hook StrictMode-safe? #

Write a cleanup function that fully tears down the socket — null all four handlers, then call close(1000). Do not rely on a guard that skips re-opening on the second mount; that leaves the dev and prod code paths divergent. With a complete cleanup, StrictMode’s mount/unmount/mount cycle opens a socket, closes it cleanly, and opens a fresh one, which is harmless. If you see a connection that never closes in the DevTools WS panel, your cleanup is incomplete.

Does this work with the ws package on the server or only browser WebSocket? #

The hook uses the browser-native WebSocket API, which the ws package implements server-side too — but you would not run this hook on the server. On the backend you pair it with a ws server. The wire protocol is identical RFC 6455 framing, so a hook written against browser WebSocket interoperates with any compliant server, including ws, Go’s gorilla, or a Node service behind a proxy.

Why null the event handlers before calling close()? #

close() is asynchronous: it begins the closing handshake and the onclose handler fires later. If the component has already unmounted by then, onclose runs a setState against a dead component, producing the “update on an unmounted component” warning and, worse, scheduling work that keeps the closure (and everything it captured) alive. Nulling the handlers first guarantees no callback fires after teardown begins.

How do I share one socket across multiple components? #

The per-component hook opens one socket per consumer. To fan a single connection out to many components, lift the socket into a React context or an external store (Zustand, a module-level singleton) and have components subscribe to it rather than each calling useWebSocket. Keep the same teardown discipline at the provider level, and reference-count subscribers so the socket closes only when the last consumer unmounts.

Back to Frontend WebSocket State Hooks & UI Patterns