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>
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.
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().