Without React
This page covers react-websocket/core: the same reconnecting, queueing, typed client, usable from plain JavaScript, Node, or a state library.
The core entry
import { createWebSocket, MessageError } from 'react-websocket/core';
react-websocket/core exports createWebSocket, MessageError and all the types. It doesn't import React and isn't marked 'use client', so it's safe in workers, Node scripts and server code. The main react-websocket entry re-exports everything from it, plus the hooks.
The client API
import { createWebSocket } from 'react-websocket/core';
type In = { type: 'price'; symbol: string; price: number };
type Out = { type: 'subscribe'; symbol: string };
const prices = createWebSocket<In, Out>('wss://api.example.com/prices', { json: true });
// Events: open, message, close, error. Each `on` returns an unsubscribe function.
const off = prices.on('message', message => {
console.log(message.symbol, message.price);
});
prices.on('close', ({ code, reason }) => console.log('closed', code, reason));
prices.on('error', error => console.error(error));
// Start, and stay connected until close().
prices.connect();
// Sent now if open, otherwise queued and sent on open.
prices.send({ type: 'subscribe', symbol: 'ACME' });
// Wait for the connection.
await prices.ready;
// Later:
off();
prices.close();
Full details are in the WebSocketClient reference.
Watching state
subscribe notifies on every state change; read the new state with getSnapshot():
const unsubscribe = prices.subscribe(() => {
const { status, attempt } = prices.getSnapshot();
statusElement.textContent = attempt ? `${status} (attempt ${attempt})` : status;
});
Holding vs. connecting
There are two ways to keep a client connected:
connect() pins the connection until close(). Use it for app-wide connections you open once.
acquire() is reference-counted, which is what the hooks use. The connection opens on the first acquire() and closes one tick after the last release function is called. Use it when several independent parts of your code use the same client:
const release = prices.acquire();
// … later, when this part of the app no longer needs it:
release();
Vanilla JavaScript
<p id="status">idle</p>
<ul id="log"></ul>
<script type="module">
import { createWebSocket } from 'https://esm.sh/react-websocket/core';
const chat = createWebSocket('wss://api.example.com/chat', { json: true });
const status = document.getElementById('status');
const log = document.getElementById('log');
chat.subscribe(() => (status.textContent = chat.getSnapshot().status));
chat.on('message', message => {
const item = document.createElement('li');
item.textContent = `${message.user}: ${message.text}`;
log.append(item);
});
chat.connect();
</script>
For components in other frameworks, the Remix 3 recipe has small bindings that stand in for each hook, and the Astro recipe holds the connection from a custom element.
Node
Outside a browser (no window), the client never reaches for a global WebSocket on its own, so that server renders don't open sockets. In Node, pass an implementation explicitly, either Node's built-in WebSocket (Node 22+) or the ws package:
import WebSocket from 'ws';
import { createWebSocket } from 'react-websocket/core';
const feed = createWebSocket<{ event: string }>('wss://api.example.com/feed', {
json: true,
WebSocket: WebSocket as unknown as typeof globalThis.WebSocket,
// `ws` delivers binary frames as Buffers by default; ask for browser-style ArrayBuffers.
binaryType: 'arraybuffer',
logger: console.debug
});
feed.on('message', ({ event }) => console.log(event));
feed.connect();
process.on('SIGINT', () => {
feed.close();
process.exit(0);
});
// Node 22+ built-in WebSocket
createWebSocket(url, { WebSocket: globalThis.WebSocket });
The immediate retry on online and visibilitychange only exists in browsers; in Node, reconnects follow the backoff alone.
Headers, proxies and TLS
A browser can't set headers on a WebSocket, but a Node client can, and it may also need a proxy or a private certificate authority. ws takes all of these as options, in a third constructor argument. The client only passes the URL and protocols, so put the options in a subclass:
// feed.ts
import { readFileSync } from 'node:fs';
import { HttpsProxyAgent } from 'https-proxy-agent';
import WebSocket from 'ws';
import { createWebSocket } from 'react-websocket/core';
import { getFreshToken } from './auth';
// Made once and shared by every connection.
const proxy = process.env.HTTPS_PROXY;
const agent = proxy ? new HttpsProxyAgent(proxy) : undefined;
const ca = readFileSync('certs/internal-ca.pem');
let token: string;
class NodeWebSocket extends WebSocket {
constructor(url: string | URL, protocols?: string | string[]) {
super(url, protocols, { headers: { Authorization: `Bearer ${token}` }, agent, ca });
// End an attempt that hangs, at the proxy or at the server.
const timer = setTimeout(() => this.terminate(), 10_000);
this.once('open', () => clearTimeout(timer)).once('close', () => clearTimeout(timer));
}
}
export const feed = createWebSocket<{ event: string }>(
async () => {
token = await getFreshToken();
return 'wss://api.example.com/feed';
},
{
json: true,
WebSocket: NodeWebSocket as unknown as typeof globalThis.WebSocket,
binaryType: 'arraybuffer'
}
);
The client constructs a socket for every connection attempt, reconnects and migrate() included. A constructor can't wait for a token, so the URL function fetches it: it runs right before each attempt and may be async. Each connection then sends a fresh token.
The ws options that matter here:
The rest are in the ws documentation.
The timer matters in Node. A server that accepts the connection but never answers, or a proxy that never answers, would hold an attempt open forever: neither the client nor ws ends it on its own. ws has a handshakeTimeout option, but it doesn't start until the proxy has answered. terminate() ends the attempt with a 1006 close, and the client retries it with the usual backoff.
Don't turn off certificate checks
rejectUnauthorized: false gets past a certificate error by accepting any certificate, an attacker's included. Pass the certificate authority as ca instead.
State libraries
Connect a client to a store by forwarding events. Because the client is created at module level, nothing depends on React rendering.
Zustand
Let the client own the connection and Zustand own your data. You don't need to copy status into the store: useWebSocketStatus(client) already reads it.
// chat.ts
import { create } from 'zustand';
import { createWebSocket } from 'react-websocket';
type In = { type: 'message'; id: string; text: string } | { type: 'ack'; id: string };
type Out = { type: 'send'; id: string; text: string };
type Message = { id: string; text: string; pending: boolean };
export const chat = createWebSocket<In, Out>('wss://api.example.com/chat', { json: true });
type ChatState = {
messages: Message[];
send(text: string): void;
receive(message: In): void;
};
export const useChat = create<ChatState>()(set => ({
messages: [],
send(text) {
const id = crypto.randomUUID();
set(state => ({ messages: [...state.messages, { id, text, pending: true }] })); // optimistic
chat.send({ type: 'send', id, text }); // queued while offline
},
receive(message) {
if (message.type === 'ack') {
set(state => ({
messages: state.messages.map(m => (m.id === message.id ? { ...m, pending: false } : m))
}));
} else {
set(state => ({ messages: [...state.messages, { ...message, pending: false }] }));
}
}
}));
Forward messages from one component mounted high in the tree. The socket is then open only while that part of the UI exists:
import { useWebSocketEvent } from 'react-websocket';
import { chat, useChat } from './chat';
export function ChatConnection() {
useWebSocketEvent(chat, 'message', message => useChat.getState().receive(message));
return null;
}
Components read data with useChat(state => state.messages) and status with useWebSocketStatus(chat), and send through store actions.
To keep the connection open for the whole session instead, forward with chat.on('message', …) at module level and call chat.connect().
Do you need Zustand for this?
If the store only accumulates socket data, createWebSocketState does the same with a reducer and per-component selectors, with no extra dependency. Reach for Zustand when many app actions also change the same data.
import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { createWebSocket, type Status } from 'react-websocket/core';
type Order = { id: string; state: 'open' | 'filled' };
const orders = createSlice({
name: 'orders',
initialState: { status: 'idle' as Status, byId: {} as Record<string, Order> },
reducers: {
statusChanged: (state, action: PayloadAction<Status>) => {
state.status = action.payload;
},
orderUpdated: (state, action: PayloadAction<Order>) => {
state.byId[action.payload.id] = action.payload;
}
}
});
export const store = configureStore({ reducer: { orders: orders.reducer } });
const socket = createWebSocket<Order>('wss://api.example.com/orders', { json: true });
socket.subscribe(() => {
const { status } = socket.getSnapshot();
if (status !== store.getState().orders.status) store.dispatch(orders.actions.statusChanged(status));
});
socket.on('message', order => store.dispatch(orders.actions.orderUpdated(order)));
socket.connect();
To open the connection only while some component needs it, call socket.acquire() from that component's effect (or use a hook such as useWebSocketStatus, which holds the connection while mounted) instead of connect().