esc

Type to search...

What is react-socket?

WebSockets look easy until you actually build with them. The initial new WebSocket(url) takes five minutes. Then you spend the next week wiring up reconnection, figuring out why your subscriptions vanish after a dropped connection, deduplicating messages when three components subscribe to the same channel, and tracking which sends actually reached the server. That logic ends up scattered across hooks, effects, and refs until the whole thing becomes a maintenance problem.

react socket takes that layer off your plate. You define your message types, wire up a few extractors, and the library handles socket lifecycle, reconnection, keep alive, subscription management, and in flight tracking. Twelve React hooks, one job each, give you everything you need at the call site. No message switch, no imperative glue.

Principles

  • One way per concern — Each hook has exactly one job. Send, subscribe, batch, react to an event, observe connection state, observe pending subscription — twelve hooks, zero overlap
  • Typed end to end — Discriminated unions on a configurable key drive inference through useSocketEvent. No predicate type guards, no explicit generics for the common case
  • Lifecycle in the library — Ack matching and subscription resolution are declared once as extractors. Your components never call ackInFlight or resolvePendingSubscription
  • Your protocol, your store — react socket does not render, does not touch your state manager, does not assume a wire format. It keeps the socket organized and gets out of the way

Installation

terminal
npm install @luciodale/react-socket

Quick start

Module level manager, one hook to react to incoming events, one hook to send. That is the entire setup for a fire and forget echo.

Echo.tsx
import { useEffect, useState } from "react";
import {
  WebSocketManager,
  useSocketEvent,
  useSocketSend,
} from "@luciodale/react-socket";

type TClientMsg = { type: "echo"; text: string };
type TServerMsg = { type: "echo"; text: string };

const manager = new WebSocketManager<TClientMsg, TServerMsg>({
  url: "wss://your-server.com/ws",
  serialize: (msg) => JSON.stringify(msg),
  deserialize: (raw) => JSON.parse(raw),
});

export function Echo() {
  const [response, setResponse] = useState<string | null>(null);
  const { send } = useSocketSend(manager);

  useSocketEvent(manager, "echo", (msg) => setResponse(msg.text));

  useEffect(() => {
    manager.connect();
    return () => manager.disconnect();
  }, []);

  return (
    <>
      <button onClick={() => send({ type: "echo", text: "hello" })}>
        Send
      </button>
      {response && <p>Server said: {response}</p>}
    </>
  );
}

The twelve hooks

hooks.ts
// React to an incoming message of a given type
useSocketEvent(manager, "notification", (msg) => { ... });

// Like useSocketEvent but batches matching events and flushes every flushMs
useSocketEventBatch(manager, "tick", (msgs) => { ... }, { flushMs: 100 });

// Subscribe to a server-side stream (ref counted, auto cleanup)
useSocketSubscription(manager, {
  key: roomId,
  subscribe: { type: "subscribe", channel: roomId },
  unsubscribe: { type: "unsubscribe", channel: roomId },
});

// "joining..." UI while a subscribe is pending
const joining = useSocketPendingSubscription(manager, roomId);

// Typed send fn
const { send } = useSocketSend(manager);

// Fires on every send(), even offline — optimistic UI
useSocketSendIntent(manager, ({ data, ackId }) => { ... });

// Fires when a send() returns false — message never left the client
useSocketSendFailed(manager, ({ data, ackId, reason }) => { ... });

// Fires when in-flight messages are dropped on disconnect
useSocketInFlightDrop(manager, (messages) => { ... });

// Fires after (re)connect once subs are restored
useSocketReady(manager, (restoredKeys) => { ... });

// Fires when the last subscriber for a key unsubscribes —
// 2nd arg is the original subscribe payload (first-payload wins)
useSocketLastUnsubscribe(manager, (key, subscribePayload) => { ... });

// Observable connection state
const state = useSocketConnectionState(manager);

// Fires on every connection state transition — clear ephemeral UI on drop
useSocketConnectionChange(manager, (state, prev) => { ... });

See Patterns for scenario driven examples — fire and forget, stream with history, LLM token streaming, acknowledged sends with optimistic UI, refetch after reconnect, store eviction, and more.

Two ways to observe lifecycle events

Every lifecycle moment (a send goes out, an in flight batch drops, the socket becomes ready, a ref count hits zero, connection state changes) can be observed two ways. The hooks are the primary surface; the listener methods on the manager are escape hatches for non React consumers.

  • Hooks (useSocketSendIntent, useSocketSendFailed, useSocketInFlightDrop, useSocketReady, useSocketLastUnsubscribe, useSocketConnectionState, useSocketConnectionChange) — mount on any component, auto cleanup on unmount, latest handler without resubscribing. The default for React UI.
  • Listener methods (manager.addSendIntentListener, addSendFailedListener, addInFlightDropListener, addReadyListener, addLastUnsubscribeListener, addConnectionStateListener) — same payloads, returned from the manager. Use from workers, node scripts, tests, or non React consumers. Each returns an unsubscribe fn.
  • Construction-time callbacks on new WebSocketManager({ onReady, onDebug }) — only these two. Use when you need a handler that fires during manager bootstrap, before any component has mounted.

Testing

A drop-in MockTransport ships from a dedicated subpath. Pass it as the manager's transport in tests and drive the lifecycle synchronously: no real WebSocket server, no global stubs.

example.test.ts
import { MockTransport } from "@luciodale/react-socket/testing";

const transport = new MockTransport();
const manager = new WebSocketManager({ url: "ws://test", transport, ... });

manager.connect();
transport.simulateOpen();
transport.simulateMessage(JSON.stringify({ type: "hi" }));
expect(transport.sentMessages).toHaveLength(1);

Full recipes (reconnect, ack flow, hook tests with React Testing Library, Vitest setup) live on the Testing page.

Binary frames and high-frequency streams

The wire type is a generic on the manager. Default is string; widen it to ArrayBuffer, Blob, or any union and pair with binaryType to send MessagePack, protobuf, or raw bytes. For high-frequency streams that would otherwise melt the renderer, useSocketEventBatch buffers matching events and flushes once per flushMs instead of per event. See Binary frames and Backpressure.

Next