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:

StateDescription
connectingWebSocket is being established
connectedConnection is open and receiving events
reconnectingAttempting to re-establish after a drop
closedConnection 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 WebSocket

Authentication

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

PropertyTypeDescription
lastEventT | undefinedMost recently received event
eventsT[]Accumulated events (newest last)
statestringConnection state
send(msg) => voidSend a message to the server
close() => voidClose the subscription
errorError | undefinedLast error, if any

Options

OptionTypeDefaultDescription
enabledbooleantrueToggle the subscription on/off
maxEventsnumberunlimitedMax 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.