React integration
React hooks backed by Chatpack's small external store.
Install React with the client:
pnpm add @chatpack/client react"use client";
import { createChatClient } from "@chatpack/client/react";
export const chatClient = createChatClient();
export function Messages({ conversationId }: { conversationId: string }) {
const result = chatClient.useMessages({ conversationId, limit: 50 });
if (result.isPending) return <p>Loading…</p>;
if (result.error !== null) return <p>{result.error.message}</p>;
return (
<>
<ul>
{result.data?.messages.map((message) => (
<li key={message.id}>{message.body}</li>
))}
</ul>
{result.data?.nextCursor !== null && (
<button onClick={() => void result.loadMore()}>Older</button>
)}
</>
);
}Available hooks are useConversations, useConversation, useMessages,
useMessageSearch, and useRealtimeStatus. useTyping, usePresence, and useReceipts read the
matching client plugin state. Hooks use React's useSyncExternalStore, so
React 18 and React 19 share the same cache and stream behavior.
useMessageSearch({ query, limit }) searches every conversation visible to the
signed-in participant. Results are case-insensitive, whole-token, AND-matched,
relevance-ranked, and cursor-paginated. An empty query stays idle without a
request. Unsupported adapters return SEARCH_UNSUPPORTED; successful results
are snapshots: edits and tombstones patch loaded hits in place, and losing
conversation access removes its hits, but new messages are not inserted or
re-ranked. Call refetch() to recompute matches and rank.
const search = chatClient.useMessageSearch({ query: "release ready", limit: 20 });
if (search.error?.code === "SEARCH_UNSUPPORTED") return null;The hook requests on every query change. Debounce text input before passing it to the hook (about 250 ms is a useful starting point); the client also bounds its per-instance search cache to ten normalized queries.
useConversations opens the stream and re-renders on incoming messages: the
list reorders most-recently-active-first and unread badges update on their own,
so a conversation list needs no event subscription of its own.
export function ConversationList() {
const { data } = chatClient.useConversations();
return (
<ul>
{data?.conversations.map((conversation) => (
<li key={conversation.id}>
{conversation.id} {conversation.unreadCount > 0 && <b>{conversation.unreadCount}</b>}
</li>
))}
</ul>
);
}Pass userId to createChatClient so the viewer's own messages never count as
unread. See Client realtime for the full cache rules.
useRealtimeStatus() returns { status, error } with no data wrapper.
status is idle, connecting, open, closed, or polling — the last
meaning the stream is unavailable and data is being kept live by
interval refetch instead. Treat it as
connected-but-degraded: the list and open thread update, but useTyping,
usePresence and useReceipts stay empty.
SSR does not open an SSE connection. The connection starts after a browser subscriber or any data hook mounts.