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.
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.
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],
},
})),
}));function ChatBridge() {
useSocketEvent(manager, "chat", (msg) => {
useChatStore.getState().append(msg.roomId, {
id: msg.id,
userId: msg.userId,
text: msg.text,
});
});
return null;
}// 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.
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 config
{
getSubscriptionResolvedKey: (msg) =>
msg.type === "subscribe-ack" ? msg.channel : undefined,
}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.
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 config — extractor is transport plumbing
{
getAckId: (msg) => (msg.type === "delivered" ? msg.ackId : undefined),
}// 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;
}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.
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.
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.
// 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:
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.
// 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 };// 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,
};
}),
}));// 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;
}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(...)ormanager.resolvePendingSubscription(...) -
A
useEffectwrappingsubscribe/unsubscribe - A hand rolled pub sub layer with
.on/.remove - A predicate type guard to narrow incoming messages