Skip to content

React

DurableWS ships a first-class React hook in the box: durablews/react. No extra package — React is an optional peer dependency, so installing durablews without React never warns, and the hook only loads when you import it. React 18+ is required.

Terminal window
npm install durablews

Pass a config and the hook owns the client: it connects on mount and closes the connection on unmount.

import { useWebSocket } from "durablews/react";
function Chat() {
const { state, lastMessage, send } = useWebSocket({
url: "wss://example.com/socket"
});
return (
<div>
<p>Connection: {state}</p>
<p>Last message: {JSON.stringify(lastMessage)}</p>
<button onClick={() => send({ type: "hello" })}>Say hello</button>
</div>
);
}

Everything durable-by-default applies: the connection reconnects with full-jitter backoff, send() queues while disconnected and flushes on open, and state walks through "reconnecting" so your UI can show it.

The hook is built on useSyncExternalStore — the client’s subscribe() / getState() pair is exactly that contract, with referentially stable snapshots — so it is concurrent-rendering-safe and Strict Mode’s double-effect mount/unmount cycle is handled.

PropertyTypeWhat it is
stateConnectionState"idle" → "connecting" → "open" → "reconnecting" → …
lastMessageTIn | undefinedThe latest decoded (and validated) inbound message
lastErrorEvent | Error | nullThe most recent failure, if any
retryAttemptnumberRetries used in the current disconnection episode
queueLengthnumberOutbound messages waiting for an open socket
send / connect / closefunctionsProxies to the client
clientWebSocketClientThe full client, for everything else (on(), use(), …)

lastMessage keeps only the latest message — DurableWS never accumulates message history. To process every message, subscribe on the client in an effect:

const { client } = useWebSocket({ url });
useEffect(() => {
// on() returns its own unsubscribe — ready-made effect cleanup.
return client.on("message", (msg) => {
// every message, not just the latest
});
}, [client]);

Pass a Standard Schema (zod, valibot, arktype, …) and lastMessage is fully typed — plus every inbound message is validated at runtime:

import { useWebSocket } from "durablews/react";
import { z } from "zod";
const Message = z.object({ type: z.string(), body: z.string() });
function Chat() {
const { lastMessage } = useWebSocket({
url: "wss://example.com/socket",
schema: Message
});
// lastMessage: { type: string; body: string } | undefined
}

The config is captured on first render — changing it later does not recreate the client, so an inline config object is fine (no useMemo needed).

Pass an existing client instead of a config and the hook only observes it — it never connects or closes a client it was handed. This is the pattern for an app-wide connection used by many components:

// src/ws.ts — the app owns this client
import { defineClient } from "durablews";
export const ws = defineClient({ url: "wss://example.com/socket" });
ws.connect();
import { useWebSocket } from "durablews/react";
import { ws } from "./ws";
function StatusBadge() {
// Reactive views over the shared connection; unmounting this
// component does not close it.
const { state } = useWebSocket(ws);
return <span>{state}</span>;
}

The hook is SSR-safe out of the box: connecting happens in an effect, and effects don’t run on the server. The server render sees the client’s initial snapshot (state: "idle").