What is react-socket?
WebSockets look easy until you actually build with them. The initial
new WebSocket(url) takes five minutes. Then
you spend the next week wiring up reconnection, figuring out why your
subscriptions vanish after a dropped connection, deduplicating
messages when three components subscribe to the same channel, and
tracking which sends actually reached the server. That logic ends up
scattered across hooks, effects, and refs until the whole thing
becomes a maintenance problem.
react socket takes that layer off your plate. You define your message types, wire up a few extractors, and the library handles socket lifecycle, reconnection, keep alive, subscription management, and in flight tracking. Twelve React hooks, one job each, give you everything you need at the call site. No message switch, no imperative glue.
Principles
- One way per concern — Each hook has exactly one job. Send, subscribe, batch, react to an event, observe connection state, observe pending subscription — twelve hooks, zero overlap
- Typed end to end — Discriminated unions on a configurable key drive inference through useSocketEvent. No predicate type guards, no explicit generics for the common case
- Lifecycle in the library — Ack matching and subscription resolution are declared once as extractors. Your components never call ackInFlight or resolvePendingSubscription
- Your protocol, your store — react socket does not render, does not touch your state manager, does not assume a wire format. It keeps the socket organized and gets out of the way
Installation
npm install @luciodale/react-socketQuick start
Module level manager, one hook to react to incoming events, one hook to send. That is the entire setup for a fire and forget echo.
import { useEffect, useState } from "react";
import {
WebSocketManager,
useSocketEvent,
useSocketSend,
} from "@luciodale/react-socket";
type TClientMsg = { type: "echo"; text: string };
type TServerMsg = { type: "echo"; text: string };
const manager = new WebSocketManager<TClientMsg, TServerMsg>({
url: "wss://your-server.com/ws",
serialize: (msg) => JSON.stringify(msg),
deserialize: (raw) => JSON.parse(raw),
});
export function Echo() {
const [response, setResponse] = useState<string | null>(null);
const { send } = useSocketSend(manager);
useSocketEvent(manager, "echo", (msg) => setResponse(msg.text));
useEffect(() => {
manager.connect();
return () => manager.disconnect();
}, []);
return (
<>
<button onClick={() => send({ type: "echo", text: "hello" })}>
Send
</button>
{response && <p>Server said: {response}</p>}
</>
);
}The twelve hooks
// React to an incoming message of a given type
useSocketEvent(manager, "notification", (msg) => { ... });
// Like useSocketEvent but batches matching events and flushes every flushMs
useSocketEventBatch(manager, "tick", (msgs) => { ... }, { flushMs: 100 });
// Subscribe to a server-side stream (ref counted, auto cleanup)
useSocketSubscription(manager, {
key: roomId,
subscribe: { type: "subscribe", channel: roomId },
unsubscribe: { type: "unsubscribe", channel: roomId },
});
// "joining..." UI while a subscribe is pending
const joining = useSocketPendingSubscription(manager, roomId);
// Typed send fn
const { send } = useSocketSend(manager);
// Fires on every send(), even offline — optimistic UI
useSocketSendIntent(manager, ({ data, ackId }) => { ... });
// Fires when a send() returns false — message never left the client
useSocketSendFailed(manager, ({ data, ackId, reason }) => { ... });
// Fires when in-flight messages are dropped on disconnect
useSocketInFlightDrop(manager, (messages) => { ... });
// Fires after (re)connect once subs are restored
useSocketReady(manager, (restoredKeys) => { ... });
// Fires when the last subscriber for a key unsubscribes —
// 2nd arg is the original subscribe payload (first-payload wins)
useSocketLastUnsubscribe(manager, (key, subscribePayload) => { ... });
// Observable connection state
const state = useSocketConnectionState(manager);
// Fires on every connection state transition — clear ephemeral UI on drop
useSocketConnectionChange(manager, (state, prev) => { ... });See Patterns for scenario driven examples — fire and forget, stream with history, LLM token streaming, acknowledged sends with optimistic UI, refetch after reconnect, store eviction, and more.
Two ways to observe lifecycle events
Every lifecycle moment (a send goes out, an in flight batch drops, the socket becomes ready, a ref count hits zero, connection state changes) can be observed two ways. The hooks are the primary surface; the listener methods on the manager are escape hatches for non React consumers.
- Hooks
(
useSocketSendIntent,useSocketSendFailed,useSocketInFlightDrop,useSocketReady,useSocketLastUnsubscribe,useSocketConnectionState,useSocketConnectionChange) — mount on any component, auto cleanup on unmount, latest handler without resubscribing. The default for React UI. - Listener methods
(
manager.addSendIntentListener,addSendFailedListener,addInFlightDropListener,addReadyListener,addLastUnsubscribeListener,addConnectionStateListener) — same payloads, returned from the manager. Use from workers, node scripts, tests, or non React consumers. Each returns an unsubscribe fn. - Construction-time callbacks
on
new WebSocketManager({ onReady, onDebug })— only these two. Use when you need a handler that fires during manager bootstrap, before any component has mounted.
Testing
A drop-in MockTransport ships from a dedicated
subpath. Pass it as the manager's transport in
tests and drive the lifecycle synchronously: no real WebSocket server,
no global stubs.
import { MockTransport } from "@luciodale/react-socket/testing";
const transport = new MockTransport();
const manager = new WebSocketManager({ url: "ws://test", transport, ... });
manager.connect();
transport.simulateOpen();
transport.simulateMessage(JSON.stringify({ type: "hi" }));
expect(transport.sentMessages).toHaveLength(1);Full recipes (reconnect, ack flow, hook tests with React Testing Library, Vitest setup) live on the Testing page.
Binary frames and high-frequency streams
The wire type is a generic on the manager. Default is
string; widen it to
ArrayBuffer, Blob, or
any union and pair with binaryType to send
MessagePack, protobuf, or raw bytes. For high-frequency streams that
would otherwise melt the renderer,
useSocketEventBatch buffers matching events
and flushes once per
flushMs instead of per event. See
Binary frames
and
Backpressure.
Next
- Patterns — concrete scenarios
- API Reference — every hook and manager method
- Configuration — extractors, discriminator, transport
- Testing — MockTransport, simulating lifecycle, hook tests
- Binary frames — ArrayBuffer / Blob, MessagePack, mixed string + binary
- Backpressure — useSocketEventBatch, picking flushMs