Recipes

Two kinds of page live here. The real-world guides build a whole app, client and server, and show what happens when the network misbehaves. The patterns below them are short and self-contained: copy what you need.

Real-world guides

Each guide's code is complete and type-checked against the library, and was run in Chrome against its server, with stand-ins for third-party APIs where a guide uses one.

AI apps

AI chatbot

Streamed answers that survive reconnects and reloads, a Stop button that stops the model, and tools the user approves. The server calls Claude.

createWebSocketRuns · useAgentRun · createWebSocketResponder · useAgentApproval

AI image and video generator

A queue with live positions, progress and previews, jobs that carry on through reloads, and one gallery on every device.

createWebSocketRuns · useAgentRun · createWebSocketState · heartbeat

Voice assistant and text to speech

Microphone audio up, the answer down, interruptions cut back to what was heard, and narration that streams and downloads.

volatile · maxBuffered · priority · createWebSocketRequests

Multiplayer games

Multiplayer party game

A trivia host screen and phones: rooms with a join code, a countdown every screen agrees on, answers counted once, and phones that rejoin after they sleep.

createWebSocketRequests · createWebSocketPresence · createWebSocketLatency · createWebSocketState

Real-time action game

The game example, file by file: a fixed-tick server, prediction and interpolation, an adaptive send rate and a 60 fps canvas.

react-websocket/timeline · createWebSocketSendRate · createWebSocketLatency · react-websocket/chaos

Collaboration

Team chat

Channels where every message arrives once and in order, an outbox that survives reloads, unread counts on every device, and one connection for all tabs.

createWebSocketFeeds · react-websocket/outbox · createWebSocketPresence · react-websocket/tabs

Collaborative whiteboard

Live cursors, strokes others watch as you draw, a snapshot for late joiners, drawing offline, and notes merged by Yjs.

createWebSocketPresence · createWebSocketSendRate · createWebSocketFeeds · react-websocket/outbox

Live data

Trading dashboard

A 500-symbol watchlist that streams only the rows on screen and renders once per frame, an order book that stays exact through lost messages, and orders placed exactly once.

createWebSocketTopics · createWebSocketFeeds · useInView · batch: 'frame'

Live notifications

One connection for every tab, an unread badge that resumes from the last notice after a reload, reads that survive going offline, and sign-out everywhere.

react-websocket/tabs · createWebSocketFeeds · react-websocket/outbox · useWebSocketOutbox

Delivery tracking

A courier marker that glides between GPS fixes without React renders, an ETA on the server's clock, and proof of delivery that gets through a dead zone.

react-websocket/timeline · createWebSocketLatency · createWebSocketSendRate · react-websocket/outbox

Many message types on one socket

Pub/sub topics, Django Channels groups and similar features all deliver different kinds of message over the same socket. Tag each message on the server with a discriminant field, type it as a union, and route on the client:

import { useState } from 'react';
import { createWebSocket, useWebSocketEvent } from 'react-websocket';

type LiveEvent =
  | { group: 'prices'; symbol: string; price: number }
  | { group: 'chat'; user: string; text: string }
  | { group: 'presence'; online: string[] };

export const live = createWebSocket<LiveEvent>('wss://api.example.com/ws', { json: true });

function ChatLog() {
  const [lines, setLines] = useState<string[]>([]);
  useWebSocketEvent(live, 'message', event => {
    // TypeScript narrows `event` from the `group` check.
    if (event.group === 'chat') setLines(list => [...list, `${event.user}: ${event.text}`]);
  });
  return (
    <ul>
      {lines.map((line, i) => (
        <li key={i}>{line}</li>
      ))}
    </ul>
  );
}

Each component listens for what it cares about, and they all share one connection. To validate the union at runtime, pass a schema.

Request/response

WebSockets have no built-in replies. A common convention is to send an id with each request and have the server echo it in the reply. This helper wraps that in a promise with a timeout:

// rpc.ts
import { createWebSocket, type WebSocketClient } from 'react-websocket';

// Messages the server sends: replies carry the id of the request they answer.
type ServerMessage =
  | { type: 'reply'; id: string; result: unknown }
  | { type: 'failure'; id: string; error: string }
  | { type: 'event'; name: string; data: unknown };

type ClientMessage = { type: 'call'; id: string; method: string; params?: unknown };

export const api = createWebSocket<ServerMessage, ClientMessage>('wss://api.example.com/rpc', {
  json: true
});

export function request<Result>(
  client: WebSocketClient<ServerMessage, ClientMessage>,
  method: string,
  params?: unknown,
  { timeout = 10_000 }: { timeout?: number } = {}
): Promise<Result> {
  const id = crypto.randomUUID();
  return new Promise<Result>((resolve, reject) => {
    const off = client.on('message', message => {
      if (message.type === 'event' || message.id !== id) return;
      clearTimeout(timer);
      off();
      if (message.type === 'reply') resolve(message.result as Result);
      else reject(new Error(message.error));
    });
    const timer = setTimeout(() => {
      off();
      reject(new Error(`${method} timed out after ${timeout} ms`));
    }, timeout);
    client.send({ type: 'call', id, method, params });
  });
}
type User = { id: string; name: string };
const user = await request<User>(api, 'getUser', { id: '42' });

If the socket isn't open, the request is queued and sent on connect. The timer starts immediately, so pick a timeout that covers a reconnect. If the connection drops after the request was sent, the server may never reply; the timeout turns that into a rejection.

Following a changing URL

With useWebSocket(url), a new URL closes the old connection and opens the new one. Build the URL from state:

function Room({ roomId }: { roomId: string }) {
  const { status, lastMessage } = useWebSocket<string>(
    `wss://api.example.com/rooms/${encodeURIComponent(roomId)}`
  );
  return (
    <p>
      {roomId} ({status}): {lastMessage}
    </p>
  );
}

Each distinct URL is its own shared connection. If the URL comes from a text field, debounce it so you don't connect on every keystroke.

To switch rooms on one connection instead, keep a single client and let topic subscriptions send the join/leave messages:

// rooms.ts
import { createWebSocket, createWebSocketTopics } from 'react-websocket';

type RoomIn = { roomId: string; user: string; text: string };
type RoomOut = { type: 'join' | 'leave'; roomId: string };

export const rooms = createWebSocket<RoomIn, RoomOut>('wss://api.example.com/rooms', { json: true });

export const roomTopics = createWebSocketTopics(rooms, {
  subscribe: roomId => ({ type: 'join', roomId }),
  unsubscribe: roomId => ({ type: 'leave', roomId })
});
import { useWebSocketTopic } from 'react-websocket';
import { roomTopics } from './rooms';

function Room({ roomId }: { roomId: string }) {
  useWebSocketTopic(roomTopics, roomId); // joins roomId, leaves the previous one
  // …
}

Calling send from an effect yourself gets two things wrong. The server forgets rooms when the connection drops, so nothing joins them again after a reconnect. And send queues while disconnected, so a leave for a room you switched away from while offline goes out after the reconnect, for a room the new connection never joined. Topics join every held room again each time the socket opens, and never queue join or leave.

Presence and typing indicators

Send typing events at most every couple of seconds, and keep the list of who's typing in state:

import { useRef, useState } from 'react';
import { createWebSocket, useWebSocketEvent } from 'react-websocket';

type In = { type: 'typing'; user: string } | { type: 'message'; user: string; text: string };
type Out = { type: 'typing' } | { type: 'message'; text: string };

const chat = createWebSocket<In, Out>('wss://api.example.com/chat', { json: true });

export function Composer() {
  const [typing, setTyping] = useState<string[]>([]);
  const lastSent = useRef(0);
  const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());

  useWebSocketEvent(chat, 'message', event => {
    if (event.type !== 'typing') return;
    const { user } = event;
    setTyping(users => (users.includes(user) ? users : [...users, user]));
    // Restart this user's timer, so an earlier timeout doesn't hide them while they're still typing.
    clearTimeout(timers.current.get(user));
    timers.current.set(
      user,
      setTimeout(() => {
        timers.current.delete(user);
        setTyping(users => users.filter(u => u !== user));
      }, 3000)
    );
  });

  function onInput() {
    const now = Date.now();
    if (now - lastSent.current < 2000) return;
    lastSent.current = now;
    // Typing events are only useful live: don't queue them while disconnected.
    if (chat.getSnapshot().status === 'open') chat.send({ type: 'typing' });
  }

  return (
    <>
      <textarea onInput={onInput} />
      {typing.length > 0 && <p>{typing.join(', ')} typing…</p>}
    </>
  );
}

Polling over a socket

The server decides when to push. If you need to ask on an interval, call send from your own timer:

import { useEffect } from 'react';
import { useWebSocket } from 'react-websocket';

function Stats() {
  const { lastMessage, send, status } = useWebSocket<{ load: number }, { type: 'stats' }>(
    'wss://api.example.com/stats',
    { json: true }
  );

  useEffect(() => {
    if (status !== 'open') return; // don't pile up queued polls while offline
    send({ type: 'stats' });
    const id = setInterval(() => send({ type: 'stats' }), 5000);
    return () => clearInterval(id);
  }, [status, send]);

  return <p>Load: {lastMessage?.load ?? '…'}</p>;
}

Optimistic sends

send() never throws for a disconnected socket: it queues the message and returns false. Show the message immediately, mark whether it actually went out, and confirm it when the server echoes it back:

// Assumes the server acks each message: In includes { type: 'ack'; id: string },
// Out includes { type: 'message'; id: string; text: string }.
type Pending = { id: string; text: string; state: 'sent' | 'queued' | 'confirmed' };

function onSubmit(text: string) {
  const id = crypto.randomUUID();
  const sentNow = chat.send({ type: 'message', id, text });
  setMessages(list => [...list, { id, text, state: sentNow ? 'sent' : 'queued' }]);
}

useWebSocketEvent(chat, 'message', event => {
  if (event.type === 'ack') {
    setMessages(list => list.map(m => (m.id === event.id ? { ...m, state: 'confirmed' } : m)));
  }
});

Queued messages go out in order when the connection opens. To bound memory during a long outage, set queue: { limit }. close() and giving up both clear the queue, so messages still marked queued at that point were never sent.

Pausing a connection

enabled: false releases a component's hold on the connection. When no other component holds it, it closes; enabled: true connects again:

function Ticker({ paused }: { paused: boolean }) {
  const { status, lastMessage } = useWebSocket<string>('wss://api.example.com/ticker', {
    enabled: !paused
  });
  return <p>{paused ? 'Paused' : `${status}: ${lastMessage ?? '…'}`}</p>;
}

A null URL works the same way: useWebSocket(isLoggedIn ? url : null).

Content hidden by React's <Activity> releases its connections automatically.

Logging and telemetry

The logger option receives the client's debug messages: connection attempts, retry delays, heartbeat timeouts and dropped messages. Route it to your telemetry, and listen to events for structured data:

import { createWebSocket, MessageError } from 'react-websocket';
import { telemetry } from './telemetry';

export const live = createWebSocket('wss://api.example.com/live', {
  json: true,
  logger: (message, ...details) => telemetry.debug(`[ws] ${message}`, details)
});

live.on('close', ({ code, reason, wasClean }) => {
  if (!wasClean) telemetry.warn('ws.close', { code, reason });
});

live.on('error', error => {
  if (error instanceof MessageError) telemetry.error('ws.bad_message', { data: error.data, issues: error.issues });
});

live.subscribe(() => {
  const { status, attempt } = live.getSnapshot();
  if (status === 'reconnecting') telemetry.count('ws.reconnect', { attempt });
});

In development, logger: console.debug is often all you need.

Hook URL or shared client?

useWebSocket(url)createWebSocket() + hooks
SetupInline in a componentOne module-level definition
SharingComponents using the same URL share one socketExplicit: everything importing the client shares it
OptionsFrom the first component to mountDefined once, with the client
URL from props/stateNatural: change the URL to switchCreate one client per URL, or use topics for join/leave
Auth token per attemptNot available (string URLs only)url can be a function
Use outside ReactNoYes: send, on, ready work anywhere

Start with useWebSocket(url) for a socket used by one component. Move to a shared client when several components, non-React code, or a URL function are involved.