API at a glance
react-socket exposes a single manager class and twelve React hooks. Each
hook has one job. You never need to write a message switch, never touch
an internal lifecycle method from user code, never manually wire a
useEffect around a subscribe call.
import {
// Construct once, usually module level
WebSocketManager,
// React to an incoming message of a given type
useSocketEvent,
// Like useSocketEvent but batches events and flushes every flushMs
useSocketEventBatch,
// Manage a server-side stream subscription lifecycle
useSocketSubscription,
// True while a subscribe is in flight
useSocketPendingSubscription,
// Typed send fn bound to the manager
useSocketSend,
// Fires on every send(), even when offline — optimistic UI
useSocketSendIntent,
// Fires when a send() returns false — the message never left the client
useSocketSendFailed,
// Fires when in-flight messages are dropped on disconnect
useSocketInFlightDrop,
// Fires after (re)connect, with the list of restored subscription keys
useSocketReady,
// Fires when the last subscriber for a key unsubscribes
useSocketLastUnsubscribe,
// Observable connection state
useSocketConnectionState,
// Fires on every connection state transition — react without rendering
useSocketConnectionChange,
} from "@luciodale/react-socket";See Patterns for scenario driven examples.
WebSocketManager
The core class. Five generics: client message type, server message
type, the discriminator key (defaults to "type"),
and two wire-data generics for binary frames (TWire
and TIncoming, both default to
string). See Configuration
for all config options and Binary frames
for the wire-type generics.
import { WebSocketManager } from "@luciodale/react-socket";
type TClientMsg = { type: "echo"; text: string };
type TServerMsg = { type: "echo"; text: string };
const manager = new WebSocketManager<TClientMsg, TServerMsg>({
url: "wss://...",
serialize: (msg) => JSON.stringify(msg),
deserialize: (raw) => JSON.parse(raw),
});Lifecycle
// Open the connection. No-op if already connected or connecting.
manager.connect();
// Graceful close (code 1000). Stops reconnection.
manager.disconnect();
// Tear down and reconnect from scratch. Coalesced state transition:
// connected → connecting (no transient "disconnected" frame).
manager.forceReconnect();
// Permanently shut down. Calling connect() again throws — construct a
// new manager instead.
manager.dispose();useSocketEvent
Subscribe a handler to one discriminator value. The message is narrowed
automatically via
Extract<TServerMsg, Record<TKey, TValue>>. Handler identity can change between renders without resubscribing.
useSocketEvent(manager, "notification", (msg) => {
// msg is narrowed to the "notification" variant of TServerMsg
toast(msg.title);
queryClient.invalidateQueries({ queryKey: ["inbox"] });
});useSocketEventBatch
Same narrowing as useSocketEvent, but buffers
matching events and flushes the batch on a fixed interval. Required for
high-frequency streams where per-event renders would tank the UI.
flushMs has no default — pick one. See the
Backpressure guide for picking values.
useSocketEventBatch(
manager,
"tick",
(msgs) => setLatest((prev) => mergeTicks(prev, msgs)),
{ flushMs: 100 },
);useSocketSubscription
Owns the lifecycle of a ref counted subscription. Sends the
subscribe payload on mount and the
unsubscribe payload on unmount. Both are typed
against TClientMsg. The
key is the ref counted identifier the library
uses internally.
useSocketSubscription(manager, {
key: roomId,
subscribe: { type: "subscribe", channel: roomId },
unsubscribe: { type: "unsubscribe", channel: roomId },
});useSocketPendingSubscription
Returns true while the subscribe has been sent
but the server has not yet confirmed via the
getSubscriptionResolvedKey extractor. Drives
"joining..." UI.
const joining = useSocketPendingSubscription(manager, roomId);
return <span>{joining ? "joining..." : "ready"}</span>;useSocketSend
Returns a stable send fn bound to the manager.
Positional signature: message first, optional ack id second. The
message is typed against TClientMsg.
const { send } = useSocketSend(manager);
function onSubmit(text: string) {
const id = crypto.randomUUID();
send({ type: "message", id, text }, id);
}
For a disabled state based on connection status, compose with
useSocketConnectionState — deliberately kept
separate so components that do not need a disabled state do not
re render on every state transition.
useSocketSendIntent
Fires on every send() call, before the wire
attempt, even when offline. The right place for optimistic UI insert.
Receives the full TSendParams (data + ackId).
useSocketSendIntent(manager, (params) => {
if (params.data.type === "message" && params.ackId) {
useChatStore.getState().insertOptimistic(params.data, params.ackId);
}
});useSocketSendFailed
Fires when send() returns
false — the message never left the client.
reason is
"not-connected" (socket down at send time),
"serialize-error" (serialize threw) or
"transport-error" (the wire write threw). The
outcome counterpart to useSocketSendIntent:
mark optimistic UI as failed or enqueue for resend centrally, without
checking the boolean at every call site. Messages that reached the
wire but die unacked surface through
useSocketInFlightDrop instead.
useSocketSendFailed(manager, ({ data, ackId, reason }) => {
if (ackId) useChatStore.getState().markFailed(ackId);
});useSocketInFlightDrop
Fires when a disconnect or forced reconnect clears messages that were
sent with an ackId but never acked. Use to
mark pending sends as failed or queue them for retry.
useSocketInFlightDrop(manager, (messages) => {
for (const { id, data } of messages) {
useChatStore.getState().markFailed(id);
}
});useSocketReady
Fires after every connect once subscriptions
are restored. The handler receives the list of keys that were replayed
to the server — also fires on the very first connect with
restoredKeys = []. Good place to refetch REST
queries that may have missed events during the disconnect.
useSocketReady(manager, (restoredKeys) => {
qc.invalidateQueries({ queryKey: ["inbox"] });
if (restoredKeys.length > 0) {
console.log("restored streams:", restoredKeys);
}
});useSocketLastUnsubscribe
Fires when the ref count for a subscription key drops to zero. Receives the key and the original subscribe payload (first-payload wins) — useful when eviction logic needs the join context. Typical use is store eviction for a channel that nobody is watching anymore.
useSocketLastUnsubscribe(manager, (key, subscribePayload) => {
if (key.startsWith("room:")) {
useChatStore.getState().evictRoom(key);
}
});useSocketConnectionState
Observable connection state. Built on
useSyncExternalStore so it is safe under
concurrent rendering.
const state = useSocketConnectionState(manager);
// "idle" | "disconnected" | "connecting" | "connected" | "reconnecting"
// "idle" = connect() never called — distinguishes "not started" from "offline"useSocketConnectionChange
The event counterpart to
useSocketConnectionState: fires the handler
on every transition with the new and previous state, without
re-rendering the component. The right place to clear ephemeral UI
(typing indicators, live cursors, presence dots) the moment the
socket drops. Does not fire on mount. For "reconnected and
subscriptions restored" use useSocketReady —
this fires on the raw transition, before subscription replay.
useSocketConnectionChange(manager, (state, prev) => {
if (state !== "connected") clearTypingIndicators();
});Manager read APIs
These are not hooks. Use them from outside React or from imperative
code that already has the manager reference. Targeted queries
(getConnectionState, getRefCount,
hasPendingSubscription) for single-key checks;
getSnapshot() for everything else in one read.
// Connection
manager.getConnectionState();
manager.addConnectionStateListener(listener); // returns unsubscribe
// Messages
manager.addMessageListener(listener); // firehose: every parsed frame
manager.addEventListener(value, listener); // O(1) keyed dispatch on the discriminator
// Subscriptions
manager.subscribe(key, data);
manager.unsubscribe(key, data);
manager.getRefCount(key);
manager.hasPendingSubscription(key);
manager.addPendingSubscriptionListener(listener); // returns unsubscribe
// Send
manager.send({ data, ackId? });
// Lifecycle listeners (every one returns an unsubscribe)
manager.addSendIntentListener(listener);
manager.addInFlightDropListener(listener);
manager.addReadyListener(listener);
manager.addLastUnsubscribeListener(listener);
manager.addDebugListener(listener);
// Protocols (replace, not append)
manager.setProtocols(["graphql-ws"]);
// Snapshot — replaces the eight individual getters that used to exist
const s = manager.getSnapshot();
// s.connectionState, s.subscriptionRefCounts, s.subscriptionData,
// s.pendingSubscriptions, s.inFlightMessages, s.reconnectAttempt,
// s.protocols, s.disposed, s.intentionalCloseDiscriminator override
By default the library narrows on type. If
your protocol uses kind, op, or anything else, map the wire shape once inside
deserialize — that is the recommended path.
You can also pass the discriminator as the third generic plus a
discriminator option when rewriting at the
boundary is not possible.
const manager = new WebSocketManager<TClientMsg, TServerMsg, "kind">({
url: "wss://...",
discriminator: "kind",
serialize: (msg) => JSON.stringify(msg),
deserialize: (raw) => JSON.parse(raw),
});
useSocketEvent(manager, "notification", (msg) => {
// narrowed via Extract<TServerMsg, { kind: "notification" }>
});createLocalStorage / createUndeliveredSync
Orthogonal helpers for offline message queuing. See Undelivered Sync.
import {
createLocalStorage,
createUndeliveredSync,
} from "@luciodale/react-socket";
const undelivered = createUndeliveredSync<TStoredMessage>({
storage: createLocalStorage(),
storageKey: "my_app_undelivered",
});
await undelivered.init();Types
import type {
TConnectionState, // connection state union
TManagerConfig, // constructor options
TManagerSnapshot, // shape returned by manager.getSnapshot()
TSendParams, // argument to send()
TWireData, // serialize() return: string | ArrayBuffer | ArrayBufferView | Blob
TIncomingData, // deserialize() input: string | ArrayBuffer | Blob
TDebugEvent, // id + timestamp + payload
TDebugEventPayload, // payload portion
TDebugEventType, // union of debug event type strings
IWebSocketTransport, // custom transport interface
IStorage, // async storage interface
TUndeliveredSync, // return type of createUndeliveredSync
} from "@luciodale/react-socket";Debug events
| Type | Payload |
|-------------------------------|-----------------------------------------|
| connection-state-change | { from, to } |
| message-received | { raw, deserialized, isPong } |
| message-sent | { ackId?, raw, deserialized } |
| send-failed | { ackId?, reason, deserialized, error? }|
| subscribe | { key, refCount, raw?, deserialized? } |
| unsubscribe | { key, refCount, raw?, deserialized? } |
| in-flight-ack | { ackId } |
| in-flight-drop | { ids } |
| pending-subscription-resolved | { key } |
| reconnect-scheduled | { attempt, delayMs } |
| ready | { restoredKeys } |
| deserialize-error | { raw, error } |
| url-resolve-error | { error } |
| transport-error | — |
| dispose | — |