useWebSocket
One WebSocket as a hook — a four-state connection machine, jittered backoff reconnects gated by a shouldReconnect predicate, an ordered outbound queue that covers the outage, JSON with a raw escape hatch, and a heartbeat that catches sockets that are open but dead.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-websocket.jsonPrompt
Build a React + TypeScript "useWebSocket" hook (React only; wraps the native WebSocket API).
Contract
- `useWebSocket<TIn, TOut>(url: string, options?: {
enabled?: boolean // default true
protocols?: string | string[]
onMessage?: (message: WebSocketMessage<TIn>) => void
onOpen?: () => void
onClose?: (info: WebSocketCloseInfo, meta: WebSocketReconnectMeta) => void
shouldReconnect?: (info: WebSocketCloseInfo, attempt: number) => boolean
maxRetries?: number // default 5, Infinity allowed
retryDelay?: number // default 1000
maxRetryDelay?: number // default 30000
backoffFactor?: number // default 2
jitter?: "none" | "full" | "equal" // default "equal"
random?: () => number // default Math.random
queueLimit?: number // default 100, 0 disables queueing
parse?: (raw: string) => TIn // default JSON.parse
serialize?: (value: TOut) => WebSocketPayload // default JSON.stringify
heartbeat?: { message?, interval?, timeout?, isPong? }
createWebSocket?: (url, protocols?) => WebSocketLike
}): {
status: "connecting" | "open" | "closed" | "error"
lastMessage: WebSocketMessage<TIn> | null
lastClose: WebSocketCloseInfo | null
error: Error | null
retryCount: number
queueSize: number
droppedCount: number
send: (value: TOut) => "sent" | "queued" | "dropped"
sendRaw: (payload: WebSocketPayload) => "sent" | "queued" | "dropped"
reconnect: () => void
close: (code?: number, reason?: string) => void
}`
- `WebSocketMessage<T> = { data: T | null; raw: string | ArrayBuffer | Blob; parseError: Error | null }`
— the parsed value, the frame exactly as it arrived, and why parsing failed if it did.
- `WebSocketCloseInfo = { code: number; reason: string; wasClean: boolean; heartbeatTimeout: boolean }`.
- `WebSocketReconnectMeta = { attempt: number; willReconnect: boolean; retryIn: number }`.
- `WebSocketPayload = string | ArrayBuffer | ArrayBufferView | Blob`.
- `send`, `sendRaw`, `reconnect` and `close` have stable identities.
Behavior
- **Four states, describing the hook rather than mirroring `readyState`.** The native number
cannot say "waiting out a backoff" or "gave up", which are exactly the two a UI must
render. `connecting` covers opening *and* waiting (read `retryCount` to separate them);
`open` means the handshake completed; `closed` is deliberate (`close()`, `enabled: false`,
or a peer closing with 1000); `error` is a close that will not be retried. Both terminal
states are left only by `reconnect()`.
- **The connection effect depends on `[url, enabled, protocols]` and nothing else.** Every
callback — `onMessage`, `onOpen`, `onClose`, `shouldReconnect`, `parse`, `serialize`,
`createWebSocket`, `random` — is synced into a ref by a dependency-free effect declared
*before* the connection effect. This is the invariant the hook is built around: consumers
pass inline arrows, so a callback in the dependency array tears the socket down and
rebuilds it on every render, the connection never settles, and every frame arriving in the
gap is lost with no error anywhere. Prove it with a test that re-renders ten times with a
fresh `onMessage` and asserts the constructor ran once. `protocols` is usually an inline
array literal, so key it on `JSON.stringify` and rebuild one stable identity from the key.
- **A changing URL closes cleanly before it opens.** The effect cleanup removes the
listeners, then calls `close(1000)`, and only the next effect run creates the new socket.
Never leave two live sockets feeding the same `onMessage` — that is how a room switch
starts showing the previous room's messages.
- **Reconnection is a budget plus a schedule.** A raw WebSocket never reconnects at all, and
the usual hand-rolled fix reconnects forever on a fixed interval, which turns one server
restart into a synchronised stampede. Wait
`min(retryDelay * backoffFactor^(attempt-1), maxRetryDelay)`, then apply jitter: `equal`
(default) draws `ceiling/2 + random() * ceiling/2`, `full` draws `random() * ceiling`,
`none` uses the ceiling as-is. Equal is the default because it still spreads the herd but
guarantees a minimum gap — a full-jitter draw of 3ms re-runs a TCP plus TLS plus upgrade
handshake against a server that has not finished restarting. After `maxRetries`
consecutive failures stop at `error`; a successful `open` resets the counter. Take
`random` as an option so a seeded PRNG makes a schedule reproducible in tests.
- **`shouldReconnect` is a classifier, not a switch.** A 1006 transport drop and a
`4001 token expired` are identical to a retry loop and could not be more different: one
comes back by itself, the other re-runs a doomed handshake until the tab closes. Default
to "retry everything except a clean 1000". Route the outcome: a close nobody wants retried
lands in `closed` when it was a clean 1000 and in `error` otherwise, so "the peer said
goodbye" and "this connection failed" never render the same.
- **There is no `onError`.** A WebSocket error event carries no message, no code and no
status, and the spec guarantees a close event always follows it — so listen only for
`close` and give the consumer one callback with the facts in it. The single failure that
is not a close is a throwing constructor (a URL that is not ws/wss, `ws://` on an https
page, an environment with no WebSocket): that is terminal, never retried, and reported
through `status` and `error` only.
- **An outbound queue, in order.** Sending before the socket is up, or during an outage, is
normal — not an exception. Frames go into a bounded queue, `send` returns
`"sent" | "queued" | "dropped"` instead of throwing, and the queue flushes in order on
`open`. Two details make the ordering real: `onOpen` runs *before* the flush, so an auth or
subscribe frame lands ahead of the backlog; and a `send` made while a backlog exists joins
the back of the queue instead of jumping it. Drain by swapping the ref for a fresh array in
one synchronous step, so a re-entrant send cannot double-send, and put the remainder back at
the front if the socket dies mid-flush. At `queueLimit` the oldest frame is evicted and
counted into `droppedCount`; a URL change abandons the queue (those frames were addressed
to the old endpoint) and counts that too. Nothing is discarded silently.
- **JSON by default, raw always reachable.** Parse inbound text frames with `parse`
(`JSON.parse` by default) and deliver every frame whole: a throw sets `parseError`, leaves
`data` null and keeps `raw`, because throwing inside a DOM listener kills a healthy socket
over one malformed frame and no consumer `try` can catch it. Binary frames skip `parse` and
arrive as `raw`. Outbound values go through `serialize` (`JSON.stringify`), which is called
synchronously so a circular structure throws at the call site; `sendRaw` bypasses both.
- **A heartbeat, because dead sockets often do not close.** Behind a proxy, or on a phone
that changed network, a socket regularly sits in OPEN forever with nothing flowing and no
close event ever fires. Send a ping after `interval` of silence, then give the answer
`timeout` to arrive; any inbound frame counts as the answer unless `isPong` is supplied, in
which case only a matching frame does — and a frame `isPong` claims is swallowed as
plumbing (no `onMessage`, no re-render), because a pong every 30 seconds is not application
data. A missed pong closes the socket with code 4000, marks `heartbeatTimeout` on the close
info, and enters the normal reconnect path. Inbound traffic postpones the next ping, so a
chatty socket is never pinged at all. Pings never go through the outbound queue: a ping
delivered after an outage is a lie, and it would sit in front of real messages.
- **Clamp every number.** `maxRetries` floors at 0 and accepts `Infinity`; delays reject NaN,
negatives and `Infinity` (`setTimeout(Infinity)` fires immediately, so "never" would
silently become "instantly") and cap at 2^31-1; `backoffFactor` floors at 1 so a wait can
never shrink; `maxRetryDelay` can never sit below `retryDelay`; a heartbeat interval floors
at 250ms so a keepalive cannot become a load generator; `close(code)` falls back to 1000
unless the code is 1000 or within 3000-4999, the only range browsers accept.
- **Lifecycle.** Open the first socket from a `queueMicrotask` scheduled in the effect body,
so no state is set synchronously inside an effect and StrictMode's mount, cleanup, mount
opens exactly one socket. Cleanup removes every listener, clears the retry timer and both
heartbeat timers, and closes. Reset derived state when the URL changes with a render-phase
adjustment against a tracked previous value, not a `setState` inside an effect.
- **Nothing pauses a socket in a background tab.** `enabled` is the seam — pass
`enabled: isVisible` — and the outbound queue is what makes pausing safe, because whatever
is sent while paused flushes on the way back.
Rendering & styling
- The hook renders nothing; consumers own the UI. Semantic tokens only (`bg-card`,
`text-foreground`, `text-muted-foreground`, `bg-primary`, `bg-muted`, `text-destructive`,
`border`, `ring`), merged with `cn()`.
- Render a status badge per state rather than one boolean spinner, and show `queueSize` while
disconnected so a user knows their messages are held, not lost. Announce reconnection with
a container that is `role="status"` and `aria-live="polite"` (never `assertive` — a
flapping connection would interrupt a screen reader every few seconds), and give the
terminal `error` state a real action, not just a message.
- Do not disable a control the user may be focused on while the socket is down. Keep the
composer enabled — sending is legal at any time — and use `aria-disabled` plus a handler
guard for anything genuinely inert, so focus is never thrown back to `<body>`.
- Any pulsing "live" dot needs `motion-reduce:animate-none`; the state must be readable from
text and colour alone with animation off.
Customization levers
- Message history — the hook keeps only `lastMessage` on purpose, because a busy socket would
make every frame re-render an ever-growing array. Accumulate a transcript yourself in
`onMessage` (a reducer keyed by a server id de-duplicates replays after a reconnect).
- Auth — WebSocket handshakes cannot carry custom headers. Put the token in a cookie, in the
URL query, or send it as the first frame from `onOpen`, which runs ahead of the queue flush.
- Retry policy — swap `shouldReconnect` for your close-code table, raise `maxRetries` to
`Infinity` and give up on your own schedule, or drop `jitter` to `"none"` when a
deterministic schedule matters more than herd control.
- Queue policy — `queueLimit: 0` turns queueing off for a fire-and-forget stream (cursor
positions age out anyway); evict newest instead of oldest for a command stream where the
first intent matters most; add `clearQueue()` if stale messages should not be delivered
after a long outage.
- Transport — `createWebSocket` takes anything with `readyState`, `send`, `close` and
add/removeEventListener: a scripted fake for tests and demos (the preview on this page runs
entirely on one), an adapter around another client, or a socket with
`binaryType = "arraybuffer"` set before the first frame.
- Protocol shape — `parse: raw => raw` for a plain-text protocol, a zod schema inside `parse`
to validate at the boundary, or a `TIn` union plus a discriminator so `onMessage` narrows.
- Presence and typing indicators — layer them on `send` plus a debounce; keep them out of the
hook, which is transport, not application state.Concepts
- Latest-ref callbacks, connection-only dependencies — the socket effect depends on the URL,
enabledand the subprotocols; every callback lives in a ref refreshed after each render. A callback in the dependency array is the defining bug of this hook family: consumers pass inline arrows, so the socket is closed and reopened every render, and the frames that arrive in between vanish with no error anywhere. - Clean close before reopen — a changed URL is a different conversation, so the cleanup detaches the listeners and closes with 1000 before the next effect run creates anything. The hook deliberately does not report its own goodbye through
onClose, because a room switch is not a failure; the two live sockets you get from skipping this step are how a chat starts showing the wrong room. - Backoff with jitter, against a budget — reconnects wait
min(base * factor^(n-1), cap)with equal jitter by default: half fixed, half random, so clients that dropped together do not come back together, while the fixed half guarantees a minimum gap. ExhaustingmaxRetriesis a terminalerrorrather than a quieter loop, because an endless retry is how "connecting…" becomes permanent for an offline user. - A close code is a verdict, not just a number —
shouldReconnectseparates "the network blinked" (1006, retry) from "the server judged you" (a 4001 auth close, do not retry). Getting this wrong in either direction is expensive: retrying a rejected token hammers a doomed handshake forever, and refusing to retry a transport drop leaves a live user staring at a dead socket. - The outbound queue is what makes sending honest —
sendnever throws because the socket happens to be down; it returnsqueued, the frames flush in order on the next open,onOpengets to put a handshake in front of them, and the bounded queue reports every eviction indroppedCount. A queue that silently drops is worse than no queue, because the UI keeps claiming the message went out. - A heartbeat is how you learn the socket is dead — half-dead connections behind proxies and on changing mobile networks stay OPEN with no close event at all. A ping that goes unanswered inside its window is the only evidence available; the hook then closes with 4000, flags
heartbeatTimeoutso your predicate can tell it apart from an application close code, and rejoins the ordinary reconnect path.
useMediaRecorder
MediaRecorder as a hook — start / pause / resume / stop, a live duration that excludes pauses, a container negotiated against what the browser really supports, permission and unsupported as first-class states, and every track released on unmount.
Pricing Table
A four-state pricing section with a data-derived monthly/yearly toggle and a highlighted recommended tier.