WebSocket Subscriptions
Real-time communication over WebSocket — server push, bidirectional messaging, and a React hook for managing subscription lifecycle.
Why Subscriptions?
The Problem
Building real-time features typically requires setting up a separate WebSocket server, managing connection lifecycle, serializing messages, and keeping everything type-safe. In React, you also need to handle mount/unmount cleanup and state synchronization.
The Solution
Subscription endpoints are defined alongside regular HTTP endpoints in your API contract using endpoint.subscription(). The typed client automatically detects them and returns a Subscription handle instead of a Promise. The useSubscription React hook manages the full lifecycle — connect on mount, disconnect on unmount, accumulate events, expose connection state.
Basic Usage
Subscription endpoints return a Subscription handle that is both an AsyncIterable and provides send() / close() methods:
// Server-push — consume events
const sub = client.live.events();
for await (const event of sub) {
console.log(event.action, event.id);
}
// Bidirectional — send and receive
const chat = client.live.chat();
chat.send({ text: 'Hello!' });
for await (const msg of chat) {
console.log(`${msg.user}: ${msg.text}`);
}Connection State
The state property reflects the current WebSocket connection status:
| State | Description |
|---|---|
connecting | WebSocket is being established |
connected | Connection is open and receiving events |
reconnecting | Attempting to re-establish after a drop |
closed | Connection is closed |
const sub = client.live.events();
console.log(sub.state); // 'connecting'
// Close manually
sub.close();
// Or via AbortSignal
const ac = new AbortController();
const sub2 = client.live.events({ signal: ac.signal });
ac.abort(); // closes the WebSocketAuthentication
The browser WebSocket API does not support custom headers. Auth tokens are automatically appended as a ?token= query parameter:
const client = createClient(api, {
baseUrl: 'https://api.example.com',
getToken: () => localStorage.getItem('token'),
});
// Connects to: wss://api.example.com/ws/events?token=<jwt>
const sub = client.live.events();React — useSubscription
The useSubscription hook manages the full subscription lifecycle: connects on mount, disconnects on unmount, and provides reactive state.
import { useSubscription } from '@cleverbrush/client/react';
function LiveFeed() {
const { events, state, send, close, error } = useSubscription(
() => client.live.events(),
{ maxEvents: 100 }
);
return (
<div>
<span>{state}</span>
{events.map((e, i) => (
<div key={i}>{e.action} — #{e.id}</div>
))}
<button onClick={close}>Disconnect</button>
</div>
);
}Return Value
| Property | Type | Description |
|---|---|---|
lastEvent | T | undefined | Most recently received event |
events | T[] | Accumulated events (newest last) |
state | string | Connection state |
send | (msg) => void | Send a message to the server |
close | () => void | Close the subscription |
error | Error | undefined | Last error, if any |
Options
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Toggle the subscription on/off |
maxEvents | number | unlimited | Max events kept in the events array |
Bidirectional Example — Chat
function ChatRoom() {
const [input, setInput] = useState('');
const { events, state, send } = useSubscription(
() => client.live.chat(),
{ maxEvents: 200 }
);
const handleSend = () => {
if (!input.trim()) return;
send({ text: input });
setInput('');
};
return (
<div>
<p>Status: {state}</p>
<div>
{events.map((msg, i) => (
<p key={i}><b>{msg.user}:</b> {msg.text}</p>
))}
</div>
<input value={input} onChange={e => setInput(e.target.value)} />
<button onClick={handleSend} disabled={state !== 'connected'}>
Send
</button>
</div>
);
}Automatic Reconnection
Enable automatic reconnection with exponential backoff. Reconnection is not triggered by manual .close() calls or AbortSignal aborts — only by unexpected connection drops.
Global default
const client = createClient(api, {
baseUrl: 'https://api.example.com',
subscriptionReconnect: {
maxRetries: 10, // default: Infinity
backoffLimit: 30_000, // max delay ms (default: 30 000)
jitter: true, // ±25% random jitter (default: true)
},
});Per-call override
// Override options for this subscription:
const sub = client.live.events({
reconnect: { maxRetries: 3, jitter: false },
});
// Disable reconnection even when a global default is set:
const sub2 = client.live.events({ reconnect: false });Custom delay & predicate
const sub = client.live.events({
reconnect: {
delay: (attempt) => Math.min(500 * 2 ** (attempt - 1), 60_000),
jitter: false,
// Stop reconnecting on specific close codes:
shouldReconnect: ({ code }) => code !== 4003,
},
});Reconnection state in React
function LiveFeed() {
const { events, state } = useSubscription(
() => client.live.events({ reconnect: { maxRetries: 5 } }),
);
return (
<div>
{state === 'reconnecting' && (
<span className="badge">Reconnecting…</span>
)}
{events.map((e, i) => <div key={i}>{JSON.stringify(e)}</div>)}
</div>
);
}The default delay formula is 300 × 2^(attempt − 1)ms, matching the HTTP retry middleware's backoff for consistency. The backoffLimit caps the delay before jitter is applied.