Configuration
The manager is configured through a single options object. Three fields are required: URL and a pair of serialization functions. Everything else has sensible defaults. Every option is typed against your client and server message generics.
Options at a glance
| Prop | Type | Default | Description |
|---|---|---|---|
url * | string | (() => string | Promise<string>) | — | WebSocket endpoint URL. Pass a function to resolve it per connect (e.g. auth tokens) |
serialize * | (msg: TClientMsg) => string | — | Convert outgoing messages to strings |
deserialize * | (raw: string) => TServerMsg | — | Parse incoming strings into typed messages |
discriminator | string | "type" | Key used to narrow TServerMsg in useSocketEvent |
getAckId | (msg: TServerMsg) => string | undefined | — | Return the ack id when an incoming message confirms an outbound send. Library auto-clears the matching in-flight entry |
getSubscriptionResolvedKey | (msg: TServerMsg) => string | undefined | — | Return the subscription key when an incoming message confirms a subscribe. Library auto-clears the pending marker |
ping | () => TClientMsg | — | Function returning the ping message, called each interval |
isPong | (msg: TServerMsg) => boolean | — | Return true if an incoming message is a pong |
pingIntervalMs | number | 30000 | Milliseconds between pings |
pongTimeoutMs | number | 10000 | Milliseconds to wait for a pong before disconnecting |
pauseHeartbeatWhenHidden | boolean | true | Pause ping/pong while document.hidden and resume on visibility. Avoids spurious pong timeouts from background-tab throttling on mobile. Pass false to keep heartbeating in background tabs. Requires ping/isPong to take effect |
reconnectMaxAttempts | number | 10 | Maximum reconnection attempts. Pass Number.POSITIVE_INFINITY to retry forever |
reconnectBaseDelayMs | number | 1000 | Initial reconnection delay |
reconnectMaxDelayMs | number | 30000 | Maximum reconnection delay (cap on the exponential backoff) |
transport | IWebSocketTransport | — | Custom WebSocket transport for testing or non-browser environments |
binaryType | "blob" | "arraybuffer" | — | Tell the transport which binary type to deliver. Required when sending or receiving binary frames; widen the manager's TWire / TIncoming generics accordingly |
onReady | (restoredKeys: string[]) => void | — | Fires after every (re)connect once subscriptions are restored. Best place for first-message auth and refetch logic that must run before any component mounts |
onDebug | (event: TDebugEvent) => void | — | Fires for every internal lifecycle event. Best place for production telemetry that should always run |
Required options
const manager = new WebSocketManager<TClientMsg, TServerMsg>({
url: "wss://api.example.com/ws",
serialize: (msg) => JSON.stringify(msg),
deserialize: (raw) => JSON.parse(raw),
}); serialize and
deserialize are typed against your generics,
so TypeScript catches shape mismatches. Swap JSON for MessagePack,
Protobuf, or anything else without touching the rest of your code.
Discriminator
useSocketEvent narrows server messages by one
field. The default is "type". Override by
passing the key as the third generic plus the
discriminator option:
type TServerMsg =
| { kind: "notification"; title: string }
| { kind: "user-typing"; userId: string };
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) => {
msg.title; // narrowed
});Lifecycle extractors
Two extractors let the library auto wire ack and subscription
resolution. When either returns a string, the matching internal
bookkeeping fires before your listeners run. You never call
ackInFlight or
resolvePendingSubscription from user code.
{
// When the server confirms an outbound send, return its ack id.
// Library removes the matching in-flight entry automatically.
getAckId: (msg) => (msg.type === "delivered" ? msg.ackId : undefined),
// When the server confirms a subscribe, return the subscription key.
// Library clears the pending marker automatically —
// useSocketPendingSubscription flips to false.
getSubscriptionResolvedKey: (msg) =>
msg.type === "subscribe-ack" ? msg.channel : undefined,
}Ping / pong
Keep alive pings prevent idle connections from being killed by proxies
or load balancers. ping is a function so you
can include a fresh timestamp each tick.
{
ping: () => ({ type: "ping", timestamp: Date.now() }),
isPong: (msg) => msg.type === "pong",
pingIntervalMs: 30_000,
pongTimeoutMs: 10_000,
}Reconnection
Exponential backoff with jitter, capped. See the Reconnection guide.
{
reconnectMaxAttempts: 10,
reconnectBaseDelayMs: 1_000,
reconnectMaxDelayMs: 30_000,
}Lifecycle: config vs listener
Two lifecycle events are exposed as construction-time callbacks because they need to fire before any component mounts:
new WebSocketManager<TClientMsg, TServerMsg>({
// ...
// Fires after every (re)connect, once subscriptions are restored.
// Best place for first-message auth (no React mount needed).
async onReady(restoredKeys) {
const token = await auth.getValidAccessToken();
manager.send({ data: { type: "auth", token } });
},
// Fires for every internal event — feed monitoring, audit logs, etc.
onDebug(event) {
if (event.type === "deserialize-error") report(event.error);
},
});
Everything else uses an addXxxListener method
on the manager (or its React hook wrapper). Listeners return an
unsubscribe so teardown is automatic in React effects.
// Imperative — outside React, or in a top-level bridge.
const stop1 = manager.addSendIntentListener(({ data, ackId }) => { /* ... */ });
const stop2 = manager.addInFlightDropListener((messages) => { /* ... */ });
const stop3 = manager.addLastUnsubscribeListener((key, data) => { /* ... */ });
const stop4 = manager.addConnectionStateListener(() => {
const state = manager.getConnectionState();
});
// React — colocate with the component that owns the domain.
useSocketSendIntent(manager, ({ data, ackId }) => { /* ... */ });
useSocketInFlightDrop(manager, (messages) => { /* ... */ });
useSocketLastUnsubscribe(manager, (key, data) => { /* ... */ });
const state = useSocketConnectionState(manager);Custom transport
Default transport is the browser's native
WebSocket. Implement
IWebSocketTransport for tests or non browser
environments.
import type { IWebSocketTransport } from "@luciodale/react-socket";
const customTransport: IWebSocketTransport = {
connect(url, protocols) { /* ... */ },
disconnect(code, reason) { /* ... */ },
send(data) { /* ... */ },
readyState: 0,
onopen: null,
onclose: null,
onmessage: null,
onerror: null,
};
new WebSocketManager<TClientMsg, TServerMsg>({
url: "wss://...",
serialize: (msg) => JSON.stringify(msg),
deserialize: (raw) => JSON.parse(raw),
transport: customTransport,
});Next
- API Reference — hooks and manager surface
- Patterns — scenario driven examples
- Testing — MockTransport, simulating lifecycle, hook tests