esc

Type to search...

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.

imports.ts
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.

manager.ts
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

lifecycle.ts
// 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.tsx
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.tsx
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.tsx
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.

useSocketPendingSubscription.tsx
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.

useSocketSend.tsx
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.tsx
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.tsx
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.tsx
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.tsx
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.tsx
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.

useSocketConnectionState.tsx
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.tsx
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.

manager-api.ts
// 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.intentionalClose

Discriminator 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.

discriminator.tsx
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.

undelivered.ts
import {
  createLocalStorage,
  createUndeliveredSync,
} from "@luciodale/react-socket";

const undelivered = createUndeliveredSync<TStoredMessage>({
  storage: createLocalStorage(),
  storageKey: "my_app_undelivered",
});

await undelivered.init();

Types

types.ts
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

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                       | —                                       |