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
WebSocketAPI, 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.
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 | 500–1000 |
Lives in the resilient layer, not the core hook |
| max reconnect attempts | number |
n/a here | 5–8 |
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 readpropsorstatedirectly inside theonmessagehandler 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 withonMessageRef) or a reducer dispatch, which is identity-stable. onerrordoes not give you a reason. The browser fireserrorwith no diagnostic detail for security reasons. Do not try to branch on it — letonerrortriggerclose()and read the close code/reason inonclose, which is where the actionable signal lives.- Changing
urlmid-flight. A newurlre-runs the effect, which tears down the old socket and opens a new one. That is correct, but ifurlis 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 #
- Building a useWebSocket React hook with TypeScript — the canonical hook built up from scratch, covering typed messages, the
sendAPI, and reconnection composition. - useWebSocket cleanup and teardown patterns — the precise teardown sequence (null handlers, then close) that stops leaks during rapid unmounts and StrictMode remounts.
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.
Related #
- Building a useWebSocket React hook with TypeScript — the full hook implementation with typed messages and reconnection.
- useWebSocket cleanup and teardown patterns — teardown sequencing that prevents leaks on rapid unmount.
- Memory Leak Prevention — the broader rules for stopping
useEffectWebSocket leaks across re-renders. - State Sync & Optimistic Updates — wiring inbound frames into reactive UI state with rollback on rejection.
- Vue 3 Composables for Real-Time — the same transport lifecycle expressed in Vue’s Composition API.