esc

Type to search...

Fire and forget: toast + invalidate

When to use. An incoming event should trigger a side effect (toast, query invalidation, analytics ping) and then disappear. You do not need to render from the event; a callback is the correct primitive.

NotificationsBridge.tsx
import { useSocketEvent } from "@luciodale/react-socket";

function NotificationsBridge() {
  const qc = useQueryClient();

  useSocketEvent(manager, "notification", (msg) => {
    toast(msg.title);
    qc.invalidateQueries({ queryKey: ["inbox"] });
  });

  return null;
}

// mounted once in app shell
<AppShell>
  <NotificationsBridge />
  <Routes />
</AppShell>

Grep "notification" — one hit. Auto cleanup on unmount. Zero useEffect in user code.

Rendered state from a stream with history

When to use. Many components need to render from the same stream, and you need history. A single bridge owns the subscription, a zustand store owns the data, consumers read slices.

chat-store.ts
import { create } from "zustand";

type TChatMessage = { id: string; userId: string; text: string };

export const useChatStore = create<{
  messagesByRoom: Record<string, TChatMessage[]>;
  append: (roomId: string, msg: TChatMessage) => void;
}>((set) => ({
  messagesByRoom: {},
  append: (roomId, msg) =>
    set((s) => ({
      messagesByRoom: {
        ...s.messagesByRoom,
        [roomId]: [...(s.messagesByRoom[roomId] ?? []), msg],
      },
    })),
}));
ChatBridge.tsx
function ChatBridge() {
  useSocketEvent(manager, "chat", (msg) => {
    useChatStore.getState().append(msg.roomId, {
      id: msg.id,
      userId: msg.userId,
      text: msg.text,
    });
  });
  return null;
}
consumers.tsx
// Consumer: active room — reads its slice, no effect code
function ChatRoom({ roomId }: { roomId: string }) {
  const messages = useChatStore(
    (s) => s.messagesByRoom[roomId] ?? [],
  );
  return <MessageList messages={messages} />;
}

// Consumer: unread badge — different slice, same store
function UnreadBadge({ roomId }: { roomId: string }) {
  const count = useChatStore(
    (s) => (s.messagesByRoom[roomId] ?? []).length,
  );
  return <span>{count}</span>;
}

Subscribe only, no read

When to use. The component needs the server to keep a stream alive (presence, keep alive, typing pings) but does not render from it. Subscribing and reading are separate concerns — compose only what you need.

PresenceKeepalive.tsx
function PresenceKeepalive() {
  useSocketSubscription(manager, {
    key: "presence",
    subscribe: { type: "sub-presence" },
    unsubscribe: { type: "unsub-presence" },
  });
  return null;
}

Joining state

When to use. Show "joining..." while the subscribe is in flight. Requires getSubscriptionResolvedKey in the manager config so the library knows when the server confirmed.

manager.ts
// manager config
{
  getSubscriptionResolvedKey: (msg) =>
    msg.type === "subscribe-ack" ? msg.channel : undefined,
}
ChatRoom.tsx
function ChatRoom({ roomId }: { roomId: string }) {
  useSocketSubscription(manager, {
    key: roomId,
    subscribe: { type: "subscribe", channel: roomId },
    unsubscribe: { type: "unsubscribe", channel: roomId },
  });

  const joining = useSocketPendingSubscription(manager, roomId);
  if (joining) return <span>joining...</span>;

  return <Room />;
}

Typed send with disabled UI

When to use. Disable a send button when disconnected. Compose useSocketSend with useSocketConnectionState — they are deliberately separate so components that do not need a disabled state do not re render on every state transition.

ChatInput.tsx
function ChatInput() {
  const state = useSocketConnectionState(manager);
  const { send } = useSocketSend(manager);
  const canSend = state === "connected";

  return (
    <button
      disabled={!canSend}
      onClick={() => send({ type: "chat", text: "hi" })}
    >
      send
    </button>
  );
}

Acknowledged send with optimistic UI

When to use. Track outbound messages as pending until the server confirms. The extractor getAckId plumbs ack matching; your UI lives in onSendIntent (optimistic insert) and a useSocketEvent("delivered", ...) (flip to delivered).

manager.ts
// manager config — extractor is transport plumbing
{
  getAckId: (msg) => (msg.type === "delivered" ? msg.ackId : undefined),
}
ChatOptimisticBridge.tsx
// Bridge component mounted once in app shell
function ChatOptimisticBridge() {
  // optimistic insert: fires on every send(), even offline
  useSocketSendIntent(manager, ({ data, ackId }) => {
    if (data.type === "message" && ackId) {
      useChatStore.getState().insertOptimistic(data, ackId);
    }
  });

  // mark as delivered on server ack
  useSocketEvent(manager, "delivered", (msg) => {
    useChatStore.getState().markDelivered(msg.ackId);
  });

  // mark as failed when in-flight drops on disconnect
  useSocketInFlightDrop(manager, (messages) => {
    for (const { id } of messages) {
      useChatStore.getState().markFailed(id);
    }
  });

  return null;
}
ChatInput.tsx
function ChatInput({ roomId }: { roomId: string }) {
  const { send } = useSocketSend(manager);

  function onSend(text: string) {
    const id = crypto.randomUUID();
    send({ type: "message", id, roomId, text }, id); // 2nd arg: ackId
  }

  return <button onClick={() => onSend("hi")}>send</button>;
}

Refetch after reconnect

When to use. You cache realtime data behind a REST query (React Query, SWR, etc.) and need to invalidate after a reconnect since events may have been missed while offline. useSocketReady fires after every successful (re)connect once subscriptions are restored.

InboxRefetchBridge.tsx
function InboxRefetchBridge() {
  const qc = useQueryClient();

  useSocketReady(manager, (restoredKeys) => {
    qc.invalidateQueries({ queryKey: ["inbox"] });
    // restoredKeys is the list of subscriptions the library replayed
    // useful for logging or targeted refetches
  });

  return null;
}

Store eviction when nobody is watching

When to use. A channel accumulated history in your store, the last subscriber just unmounted, and keeping the data around forever wastes memory. Use useSocketLastUnsubscribe to evict.

ChatEvictionBridge.tsx
function ChatEvictionBridge() {
  useSocketLastUnsubscribe(manager, (key) => {
    if (key.startsWith("room:")) {
      useChatStore.getState().evictRoom(key);
    }
  });
  return null;
}

Non standard discriminator

When to use. Your server uses kind, op, or another key instead of type. Two options: rename once at the boundary, or pass the discriminator explicitly. Prefer the rename — one place, keeps library defaults everywhere.

boundary-map.ts
// Wire shape (what the server actually emits)
type TServerMsgWire =
  | { kind: "notification"; title: string }
  | { kind: "user-typing"; userId: string };

// App shape (what the library and your code use)
type TServerMsg =
  | { type: "notification"; title: string }
  | { type: "user-typing"; userId: string };

const manager = new WebSocketManager<TClientMsg, TServerMsg>({
  url: "wss://...",
  serialize: JSON.stringify,
  deserialize: (raw) => {
    const { kind, ...rest } = JSON.parse(raw) as TServerMsgWire;
    return { type: kind, ...rest } as TServerMsg;
  },
});

If rewriting the boundary is off limits, pass the discriminator explicitly:

discriminator-override.ts
new WebSocketManager<TClientMsg, TServerMsgWire, "kind">({
  url: "wss://...",
  discriminator: "kind",
  serialize: JSON.stringify,
  deserialize: (raw) => JSON.parse(raw),
});

useSocketEvent(manager, "notification", (msg) => {
  msg.title; // narrowed via Extract<..., { kind: "notification" }>
});

LLM streaming: split streaming from history

When to use. The server streams tokens as they generate. Every delta event arrives separately and you need the UI to show them as they land, without the whole conversation re-rendering on every token.

The trick is to keep streaming and history in separate store slices. History is a frozen, append-once-per-turn list — its reference doesn't change while a stream is active, so React skips reconciling it. Only the streaming buffer mutates on each token, and only the component reading that buffer re-renders.

protocol.ts
// Wire protocol — three events per assistant turn
type TServerMsg =
  | { type: "stream-start"; id: string; role: "assistant" }
  | { type: "stream-delta"; id: string; delta: string }
  | { type: "stream-end"; id: string };
chat-store.ts
// Store: two slices, different lifecycles
type TTurn = { id: string; role: string; text: string };

type TChatStore = {
  history: TTurn[];                       // frozen until a stream completes
  streaming: Record<string, string>;      // id -> accumulating text
  startStream: (id: string) => void;
  appendDelta: (id: string, delta: string) => void;
  endStream: (id: string, role: string) => void;
};

export const useChatStore = create<TChatStore>()((set) => ({
  history: [],
  streaming: {},

  startStream: (id) =>
    set((s) => ({ streaming: { ...s.streaming, [id]: "" } })),

  appendDelta: (id, delta) =>
    set((s) => ({
      streaming: {
        ...s.streaming,
        [id]: (s.streaming[id] ?? "") + delta,
      },
    })),

  endStream: (id, role) =>
    set((s) => {
      const text = s.streaming[id] ?? "";
      const { [id]: _, ...rest } = s.streaming;
      return {
        history: [...s.history, { id, role, text }],  // one spread per turn
        streaming: rest,
      };
    }),
}));
StreamBridge.tsx
// Single bridge, three events
function StreamBridge() {
  useSocketEvent(manager, "stream-start", (msg) => {
    useChatStore.getState().startStream(msg.id);
  });

  useSocketEvent(manager, "stream-delta", (msg) => {
    useChatStore.getState().appendDelta(msg.id, msg.delta);
  });

  useSocketEvent(manager, "stream-end", (msg) => {
    useChatStore.getState().endStream(msg.id, "assistant");
  });

  return null;
}
views.tsx
import { memo } from "react";
import { useShallow } from "zustand/shallow";

// History list — memoized, re-renders once per completed turn
const HistoryList = memo(function HistoryList() {
  const history = useChatStore((s) => s.history);
  return (
    <ul>
      {history.map((turn) => (
        <li key={turn.id}>
          <strong>{turn.role}:</strong> {turn.text}
        </li>
      ))}
    </ul>
  );
});

// Streaming view — reads a single slice, re-renders on every token
function StreamingTurn({ id }: { id: string }) {
  const text = useChatStore((s) => s.streaming[id] ?? "");
  return <p>{text}</p>;
}

// Active stream ids — tiny array, re-renders when streams start/end, not per token
function ActiveStreams() {
  const ids = useChatStore(useShallow((s) => Object.keys(s.streaming)));
  return ids.map((id) => <StreamingTurn key={id} id={id} />);
}

Putting it together: mount <StreamBridge /> in the app shell, render <HistoryList /> and <ActiveStreams /> stacked in the chat panel. Tokens only reach StreamingTurn. HistoryList stays completely still until stream-end, at which point the new turn appears there in a single render.

What user code never contains

  • A switch statement routing messages by type
  • A call to manager.ackInFlight(...) or manager.resolvePendingSubscription(...)
  • A useEffect wrapping subscribe / unsubscribe
  • A hand rolled pub sub layer with .on / .remove
  • A predicate type guard to narrow incoming messages

Next