Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * The connection state machine. **Four states**, and they are deliberately *not* a mirror of the
 * native numeric `readyState` — that number cannot express the two situations a UI cares about
 * most (waiting out a backoff, and having given up):
 *
 * - `connecting` — the socket is opening, **or** the hook is waiting out a backoff before the
 *   next attempt. To a UI those are one thing ("nothing is flowing yet"); read `retryCount` to
 *   tell them apart.
 * - `open` — the handshake completed, frames can go both ways, the outbound queue has flushed.
 * - `closed` — a deliberate stop: `close()`, `enabled: false`, or the peer closing with 1000.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-websocket.json

Prompt

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, enabled and 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. Exhausting maxRetries is a terminal error rather 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 numbershouldReconnect separates "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 honestsend never throws because the socket happens to be down; it returns queued, the frames flush in order on the next open, onOpen gets to put a handshake in front of them, and the bounded queue reports every eviction in droppedCount. 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 heartbeatTimeout so your predicate can tell it apart from an application close code, and rejoins the ordinary reconnect path.

On This Page